Checkpoint 4: harden native paths, authoring workflows and Player lifecycle
This commit is contained in:
@@ -19,6 +19,13 @@ jobs:
|
|||||||
- name: Windows compiler environment
|
- name: Windows compiler environment
|
||||||
if: runner.os == 'Windows'
|
if: runner.os == 'Windows'
|
||||||
uses: ilammy/msvc-dev-cmd@0b201ec74fa43914dc39ae48a89fd1d8cb592756
|
uses: ilammy/msvc-dev-cmd@0b201ec74fa43914dc39ae48a89fd1d8cb592756
|
||||||
|
- name: Enable the documented Windows long-path developer profile
|
||||||
|
if: runner.os == 'Windows'
|
||||||
|
shell: pwsh
|
||||||
|
run: |
|
||||||
|
$key = 'HKLM:\SYSTEM\CurrentControlSet\Control\FileSystem'
|
||||||
|
Write-Output "Previous LongPathsEnabled: $((Get-ItemProperty -Path $key).LongPathsEnabled)"
|
||||||
|
New-ItemProperty -Path $key -Name LongPathsEnabled -Value 1 -PropertyType DWORD -Force
|
||||||
- name: Identify pinned Windows Vulkan SDK cache
|
- name: Identify pinned Windows Vulkan SDK cache
|
||||||
if: runner.os == 'Windows'
|
if: runner.os == 'Windows'
|
||||||
id: vulkan-identity
|
id: vulkan-identity
|
||||||
@@ -28,10 +35,8 @@ jobs:
|
|||||||
id: vulkan-sdk-cache
|
id: vulkan-sdk-cache
|
||||||
uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830
|
uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830
|
||||||
with:
|
with:
|
||||||
key: ${{ steps.vulkan-identity.outputs.key }}-loader-only
|
key: ${{ steps.vulkan-identity.outputs.sdk_key }}
|
||||||
restore-keys: |
|
restore-keys: ${{ steps.vulkan-identity.outputs.sdk_legacy }}
|
||||||
${{ steps.vulkan-identity.outputs.key }}
|
|
||||||
${{ steps.vulkan-identity.outputs.legacy }}
|
|
||||||
path: .cache/windows-graphics/sdk
|
path: .cache/windows-graphics/sdk
|
||||||
- name: Build Windows Vulkan headers and loader without a GPU driver
|
- name: Build Windows Vulkan headers and loader without a GPU driver
|
||||||
if: runner.os == 'Windows'
|
if: runner.os == 'Windows'
|
||||||
|
|||||||
@@ -21,6 +21,12 @@ jobs:
|
|||||||
uses: ilammy/msvc-dev-cmd@0b201ec74fa43914dc39ae48a89fd1d8cb592756
|
uses: ilammy/msvc-dev-cmd@0b201ec74fa43914dc39ae48a89fd1d8cb592756
|
||||||
with:
|
with:
|
||||||
arch: x64
|
arch: x64
|
||||||
|
- name: Enable the documented Windows long-path developer profile
|
||||||
|
shell: pwsh
|
||||||
|
run: |
|
||||||
|
$key = 'HKLM:\SYSTEM\CurrentControlSet\Control\FileSystem'
|
||||||
|
Write-Output "Previous LongPathsEnabled: $((Get-ItemProperty -Path $key).LongPathsEnabled)"
|
||||||
|
New-ItemProperty -Path $key -Name LongPathsEnabled -Value 1 -PropertyType DWORD -Force
|
||||||
- name: Identify pinned Vulkan source cache
|
- name: Identify pinned Vulkan source cache
|
||||||
id: vulkan-identity
|
id: vulkan-identity
|
||||||
run: python tools/ci/prepare_windows_vulkan.py --cache-key
|
run: python tools/ci/prepare_windows_vulkan.py --cache-key
|
||||||
|
|||||||
@@ -107,3 +107,5 @@ if(BUILD_TESTING)
|
|||||||
endif()
|
endif()
|
||||||
|
|
||||||
include(cmake/Tutorials.cmake)
|
include(cmake/Tutorials.cmake)
|
||||||
|
|
||||||
|
include(cmake/WindowsPlatform.cmake)
|
||||||
|
|||||||
+2
-2
@@ -76,10 +76,10 @@ int run_editor_ui(Session& session, bool enable_mcp, std::uint64_t max_frames,
|
|||||||
auto encoded = png(renderer, region);
|
auto encoded = png(renderer, region);
|
||||||
const auto relative =
|
const auto relative =
|
||||||
arguments.value("path", std::string(".faset/screenshots/editor.png"));
|
arguments.value("path", std::string(".faset/screenshots/editor.png"));
|
||||||
require(std::filesystem::path(relative).extension() == ".png", "capture.path",
|
require(path_from_utf8(relative).extension() == ".png", "capture.path",
|
||||||
"Editor screenshots must use a .png path");
|
"Editor screenshots must use a .png path");
|
||||||
atomic_write(
|
atomic_write(
|
||||||
project_path(session.config().project_root, relative),
|
project_path(session.config().project_root, path_from_utf8(relative)),
|
||||||
std::string_view(reinterpret_cast<const char*>(encoded.data()), encoded.size()));
|
std::string_view(reinterpret_cast<const char*>(encoded.data()), encoded.size()));
|
||||||
return Json{{"path", relative},
|
return Json{{"path", relative},
|
||||||
{"mimeType", "image/png"},
|
{"mimeType", "image/png"},
|
||||||
|
|||||||
+17
-7
@@ -47,11 +47,11 @@ void help() {
|
|||||||
"MCP uses JSON-RPC over stdio and only exposes authoring/editor services.\n";
|
"MCP uses JSON-RPC over stdio and only exposes authoring/editor services.\n";
|
||||||
}
|
}
|
||||||
} // namespace
|
} // namespace
|
||||||
int main(int argc, char** argv) {
|
int editor_main(int argc, char** argv) {
|
||||||
using namespace faset;
|
using namespace faset;
|
||||||
using namespace faset::editor;
|
using namespace faset::editor;
|
||||||
try {
|
try {
|
||||||
std::filesystem::path project, engine = FASET_ENGINE_SOURCE, scene, capture;
|
std::filesystem::path project, engine = path_from_utf8(FASET_ENGINE_SOURCE), scene, capture;
|
||||||
std::string new_name, command;
|
std::string new_name, command;
|
||||||
int dimension = 3;
|
int dimension = 3;
|
||||||
bool mcp = false, gui = true, explicit_gui = false, wait = false;
|
bool mcp = false, gui = true, explicit_gui = false, wait = false;
|
||||||
@@ -67,15 +67,15 @@ int main(int argc, char** argv) {
|
|||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
if (arg == "--project")
|
if (arg == "--project")
|
||||||
project = value();
|
project = path_from_utf8(value());
|
||||||
else if (arg == "--engine")
|
else if (arg == "--engine")
|
||||||
engine = value();
|
engine = path_from_utf8(value());
|
||||||
else if (arg == "--new")
|
else if (arg == "--new")
|
||||||
new_name = value();
|
new_name = value();
|
||||||
else if (arg == "--dimension")
|
else if (arg == "--dimension")
|
||||||
dimension = std::stoi(value());
|
dimension = std::stoi(value());
|
||||||
else if (arg == "--scene")
|
else if (arg == "--scene")
|
||||||
scene = value();
|
scene = path_from_utf8(value());
|
||||||
else if (arg == "--mcp")
|
else if (arg == "--mcp")
|
||||||
mcp = true;
|
mcp = true;
|
||||||
else if (arg == "--gui") {
|
else if (arg == "--gui") {
|
||||||
@@ -95,7 +95,7 @@ int main(int argc, char** argv) {
|
|||||||
frames = std::stoull(text);
|
frames = std::stoull(text);
|
||||||
require(frames > 0 && frames <= 10000000, "cli.frames", "Frame count out of range");
|
require(frames > 0 && frames <= 10000000, "cli.frames", "Frame count out of range");
|
||||||
} else if (arg == "--capture")
|
} else if (arg == "--capture")
|
||||||
capture = value();
|
capture = path_from_utf8(value());
|
||||||
else
|
else
|
||||||
throw Error("cli.option", "Unknown option: " + arg);
|
throw Error("cli.option", "Unknown option: " + arg);
|
||||||
}
|
}
|
||||||
@@ -130,7 +130,7 @@ int main(int argc, char** argv) {
|
|||||||
remember_project(project);
|
remember_project(project);
|
||||||
#endif
|
#endif
|
||||||
if (scene.empty())
|
if (scene.empty())
|
||||||
scene = settings.value("start_scene", std::string());
|
scene = path_from_utf8(settings.value("start_scene", std::string()));
|
||||||
if (!scene.empty() &&
|
if (!scene.empty() &&
|
||||||
std::filesystem::exists(project_path(session.config().project_root, scene)))
|
std::filesystem::exists(project_path(session.config().project_root, scene)))
|
||||||
session.authoring().open(scene);
|
session.authoring().open(scene);
|
||||||
@@ -203,3 +203,13 @@ int main(int argc, char** argv) {
|
|||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#ifdef _WIN32
|
||||||
|
int wmain(int argc, wchar_t** argv) {
|
||||||
|
return faset::run_utf8_main(argc, argv, editor_main);
|
||||||
|
}
|
||||||
|
#else
|
||||||
|
int main(int argc, char** argv) {
|
||||||
|
return editor_main(argc, argv);
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|||||||
+30
-11
@@ -6,6 +6,7 @@
|
|||||||
#include <faset/core/io.hpp>
|
#include <faset/core/io.hpp>
|
||||||
#include <faset/player/SceneView.hpp>
|
#include <faset/player/SceneView.hpp>
|
||||||
#include <faset/runtime/Runtime.hpp>
|
#include <faset/runtime/Runtime.hpp>
|
||||||
|
#include <faset/runtime/schema.hpp>
|
||||||
#include <filesystem>
|
#include <filesystem>
|
||||||
#include <iostream>
|
#include <iostream>
|
||||||
#include <set>
|
#include <set>
|
||||||
@@ -167,12 +168,13 @@ void validatePackagedShaders(const std::filesystem::path& directory) {
|
|||||||
for (const auto* extension : {".spv", ".reflection.json"}) {
|
for (const auto* extension : {".spv", ".reflection.json"}) {
|
||||||
const auto path = shaders / (std::string(entry) + extension);
|
const auto path = shaders / (std::string(entry) + extension);
|
||||||
if (!std::filesystem::is_regular_file(path))
|
if (!std::filesystem::is_regular_file(path))
|
||||||
throw std::runtime_error("Packaged shader file is missing: " + path.string());
|
throw std::runtime_error("Packaged shader file is missing: " +
|
||||||
|
faset::path_to_utf8(path));
|
||||||
}
|
}
|
||||||
faset::render::validate_shader_bundle(shaders);
|
faset::render::validate_shader_bundle(shaders);
|
||||||
}
|
}
|
||||||
} // namespace
|
} // namespace
|
||||||
int main(int argc, char** argv) {
|
int player_main(int argc, char** argv) {
|
||||||
const auto started = Clock::now();
|
const auto started = Clock::now();
|
||||||
try {
|
try {
|
||||||
std::filesystem::path scenePath, assetsPath, capturePath, controlPath, profilePath;
|
std::filesystem::path scenePath, assetsPath, capturePath, controlPath, profilePath;
|
||||||
@@ -207,15 +209,15 @@ int main(int argc, char** argv) {
|
|||||||
return argv[++i];
|
return argv[++i];
|
||||||
};
|
};
|
||||||
if (arg == "--scene")
|
if (arg == "--scene")
|
||||||
scenePath = value();
|
scenePath = faset::path_from_utf8(value());
|
||||||
else if (arg == "--assets")
|
else if (arg == "--assets")
|
||||||
assetsPath = value();
|
assetsPath = faset::path_from_utf8(value());
|
||||||
else if (arg == "--capture")
|
else if (arg == "--capture")
|
||||||
capturePath = value();
|
capturePath = faset::path_from_utf8(value());
|
||||||
else if (arg == "--control")
|
else if (arg == "--control")
|
||||||
controlPath = value();
|
controlPath = faset::path_from_utf8(value());
|
||||||
else if (arg == "--profile")
|
else if (arg == "--profile")
|
||||||
profilePath = value();
|
profilePath = faset::path_from_utf8(value());
|
||||||
else if (arg == "--frames")
|
else if (arg == "--frames")
|
||||||
maximumFrames = count(value());
|
maximumFrames = count(value());
|
||||||
else if (arg == "--headless")
|
else if (arg == "--headless")
|
||||||
@@ -239,11 +241,12 @@ int main(int argc, char** argv) {
|
|||||||
assetsPath = scenePath.parent_path();
|
assetsPath = scenePath.parent_path();
|
||||||
if (!std::filesystem::is_directory(assetsPath))
|
if (!std::filesystem::is_directory(assetsPath))
|
||||||
throw std::invalid_argument("Asset cache directory does not exist: " +
|
throw std::invalid_argument("Asset cache directory does not exist: " +
|
||||||
assetsPath.string());
|
faset::path_to_utf8(assetsPath));
|
||||||
if (headless && maximumFrames == 0)
|
if (headless && maximumFrames == 0)
|
||||||
maximumFrames = 1;
|
maximumFrames = 1;
|
||||||
const auto sceneReadStarted = Clock::now();
|
const auto sceneReadStarted = Clock::now();
|
||||||
const auto document = faset::player::readScene(scenePath);
|
const auto document = faset::player::readScene(scenePath);
|
||||||
|
faset::runtime::validate_scene_schemas(document, faset::gameplay::schema());
|
||||||
const auto config = simulationConfig(document);
|
const auto config = simulationConfig(document);
|
||||||
const auto sceneReadFinished = Clock::now();
|
const auto sceneReadFinished = Clock::now();
|
||||||
const auto executableRoot = executableDirectory(argv[0]);
|
const auto executableRoot = executableDirectory(argv[0]);
|
||||||
@@ -406,6 +409,12 @@ int main(int argc, char** argv) {
|
|||||||
}
|
}
|
||||||
++frames;
|
++frames;
|
||||||
}
|
}
|
||||||
|
const auto completedTicks = world.snapshot().tick;
|
||||||
|
// Run normal shutdown while diagnostics are still observable. Runtime's
|
||||||
|
// destructor is a fallback and cannot print messages after this scope ends.
|
||||||
|
world.clear();
|
||||||
|
while (logCursor < world.diagnostics().size())
|
||||||
|
std::cerr << world.diagnostics()[logCursor++] << '\n';
|
||||||
if (!capturePath.empty()) {
|
if (!capturePath.empty()) {
|
||||||
if (frames == 0)
|
if (frames == 0)
|
||||||
throw std::runtime_error("No frame was rendered for capture");
|
throw std::runtime_error("No frame was rendered for capture");
|
||||||
@@ -438,8 +447,8 @@ int main(int argc, char** argv) {
|
|||||||
"renderer_readback_cpu measures map/copy/unmap wall time within that call. "
|
"renderer_readback_cpu measures map/copy/unmap wall time within that call. "
|
||||||
"GPU timestamps cover submitted rendering, not CPU work. A missing/zero GPU "
|
"GPU timestamps cover submitted rendering, not CPU work. A missing/zero GPU "
|
||||||
"timestamp is null. Frame durations exclude profile bookkeeping and final "
|
"timestamp is null. Frame durations exclude profile bookkeeping and final "
|
||||||
"capture/profile file writes. Startup begins at main(), excluding OS "
|
"capture/profile file writes. Startup begins at Player application entry "
|
||||||
"loader/launcher."},
|
"after platform argument normalization, excluding OS loader/launcher."},
|
||||||
{"startup_ms",
|
{"startup_ms",
|
||||||
{{"scene_read", milliseconds(sceneReadStarted, sceneReadFinished)},
|
{{"scene_read", milliseconds(sceneReadStarted, sceneReadFinished)},
|
||||||
{"world_initialization", milliseconds(worldStarted, rendererStarted)},
|
{"world_initialization", milliseconds(worldStarted, rendererStarted)},
|
||||||
@@ -448,7 +457,7 @@ int main(int argc, char** argv) {
|
|||||||
faset::atomic_write_json(profilePath, report);
|
faset::atomic_write_json(profilePath, report);
|
||||||
}
|
}
|
||||||
std::cout << nlohmann::json{{"frames", frames},
|
std::cout << nlohmann::json{{"frames", frames},
|
||||||
{"ticks", world.snapshot().tick},
|
{"ticks", completedTicks},
|
||||||
{"dimension", document.value("dimension", 3)},
|
{"dimension", document.value("dimension", 3)},
|
||||||
{"device", stats.device},
|
{"device", stats.device},
|
||||||
{"validation_errors", stats.validation_errors}}
|
{"validation_errors", stats.validation_errors}}
|
||||||
@@ -460,3 +469,13 @@ int main(int argc, char** argv) {
|
|||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#ifdef _WIN32
|
||||||
|
int wmain(int argc, wchar_t** argv) {
|
||||||
|
return faset::run_utf8_main(argc, argv, player_main);
|
||||||
|
}
|
||||||
|
#else
|
||||||
|
int main(int argc, char** argv) {
|
||||||
|
return player_main(argc, argv);
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|||||||
+32
-28
@@ -14,7 +14,7 @@ using ui::Kind;
|
|||||||
using ui::Widget;
|
using ui::Widget;
|
||||||
fs::path user_home() {
|
fs::path user_home() {
|
||||||
#ifdef _WIN32
|
#ifdef _WIN32
|
||||||
if (const auto* value = std::getenv("USERPROFILE"))
|
if (const auto* value = _wgetenv(L"USERPROFILE"); value && *value)
|
||||||
return fs::path(value);
|
return fs::path(value);
|
||||||
#else
|
#else
|
||||||
if (const auto* value = std::getenv("HOME"))
|
if (const auto* value = std::getenv("HOME"))
|
||||||
@@ -24,7 +24,7 @@ fs::path user_home() {
|
|||||||
}
|
}
|
||||||
fs::path recent_path() {
|
fs::path recent_path() {
|
||||||
#ifdef _WIN32
|
#ifdef _WIN32
|
||||||
if (const auto* value = std::getenv("APPDATA"))
|
if (const auto* value = _wgetenv(L"APPDATA"); value && *value)
|
||||||
return fs::path(value) / "Faset/recent-projects.json";
|
return fs::path(value) / "Faset/recent-projects.json";
|
||||||
#else
|
#else
|
||||||
if (const auto* value = std::getenv("XDG_CONFIG_HOME"); value && *value)
|
if (const auto* value = std::getenv("XDG_CONFIG_HOME"); value && *value)
|
||||||
@@ -44,20 +44,16 @@ fs::path normalize(std::string text) {
|
|||||||
throw std::runtime_error("Enter a project directory.");
|
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)
|
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.");
|
throw std::runtime_error("A directory must fit on one line.");
|
||||||
fs::path path = fs::u8path(text);
|
fs::path path = path_from_utf8(text);
|
||||||
if (text == "~")
|
if (text == "~")
|
||||||
path = user_home();
|
path = user_home();
|
||||||
else if (text.starts_with("~/") || text.starts_with("~\\"))
|
else if (text.starts_with("~/") || text.starts_with("~\\"))
|
||||||
path = user_home() / fs::u8path(text.substr(2));
|
path = user_home() / path_from_utf8(text.substr(2));
|
||||||
path = fs::weakly_canonical(fs::absolute(path));
|
path = fs::weakly_canonical(fs::absolute(path));
|
||||||
if (path.filename() == "project.faset.json")
|
if (path.filename() == "project.faset.json")
|
||||||
path = path.parent_path();
|
path = path.parent_path();
|
||||||
return 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) {
|
ProjectSelection open_project(const fs::path& path) {
|
||||||
if (!fs::is_directory(path))
|
if (!fs::is_directory(path))
|
||||||
throw std::runtime_error("Project directory does not exist.");
|
throw std::runtime_error("Project directory does not exist.");
|
||||||
@@ -87,12 +83,12 @@ Json recent_records(const fs::path& file) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
void remember(const fs::path& project, const fs::path& store) {
|
void remember(const fs::path& project, const fs::path& store) {
|
||||||
const auto selected = open_project(normalize(utf8(project)));
|
const auto selected = open_project(normalize(path_to_utf8(project)));
|
||||||
auto records = recent_records(store);
|
auto records = recent_records(store);
|
||||||
std::erase_if(records.get_ref<Json::array_t&>(), [&](const Json& record) {
|
std::erase_if(records.get_ref<Json::array_t&>(), [&](const Json& record) {
|
||||||
return !record.is_string() || record.get<std::string>() == utf8(selected.path);
|
return !record.is_string() || record.get<std::string>() == path_to_utf8(selected.path);
|
||||||
});
|
});
|
||||||
records.insert(records.begin(), utf8(selected.path));
|
records.insert(records.begin(), path_to_utf8(selected.path));
|
||||||
while (records.size() > 12)
|
while (records.size() > 12)
|
||||||
records.erase(records.end() - 1);
|
records.erase(records.end() - 1);
|
||||||
atomic_write_json(store, records);
|
atomic_write_json(store, records);
|
||||||
@@ -115,6 +111,13 @@ struct ProjectLauncher::Impl {
|
|||||||
render::Renderer& renderer;
|
render::Renderer& renderer;
|
||||||
ui::Context ui;
|
ui::Context ui;
|
||||||
render::Snapshot frame_data;
|
render::Snapshot frame_data;
|
||||||
|
float ui_scale = 1;
|
||||||
|
float logical_width() const {
|
||||||
|
return float(renderer.width()) / ui_scale;
|
||||||
|
}
|
||||||
|
float logical_height() const {
|
||||||
|
return float(renderer.height()) / ui_scale;
|
||||||
|
}
|
||||||
fs::path recents_file, browse_path;
|
fs::path recents_file, browse_path;
|
||||||
std::optional<fs::path> pending_browse;
|
std::optional<fs::path> pending_browse;
|
||||||
std::optional<ProjectSelection> selected;
|
std::optional<ProjectSelection> selected;
|
||||||
@@ -136,7 +139,7 @@ struct ProjectLauncher::Impl {
|
|||||||
[this](bool enabled) { renderer.set_text_input(enabled); },
|
[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); });
|
[this](ui::Rect r) { renderer.set_text_input_area(r.x, r.y, r.width, r.height); });
|
||||||
const auto start = initial.empty() ? user_home() / "FasetProjects/MyGame" : initial;
|
const auto start = initial.empty() ? user_home() / "FasetProjects/MyGame" : initial;
|
||||||
ui.update_text("launcher-path", utf8(start));
|
ui.update_text("launcher-path", path_to_utf8(start));
|
||||||
create = initial.empty();
|
create = initial.empty();
|
||||||
ui.find("launcher-open")->on_click = [this](Widget&) { set_mode(false); };
|
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-create")->on_click = [this](Widget&) { set_mode(true); };
|
||||||
@@ -151,7 +154,7 @@ struct ProjectLauncher::Impl {
|
|||||||
build_browser();
|
build_browser();
|
||||||
load_recents();
|
load_recents();
|
||||||
refresh();
|
refresh();
|
||||||
ui.layout(float(renderer.width()), float(renderer.height()));
|
ui.layout(float(renderer.width()), float(renderer.height()), ui_scale);
|
||||||
ui.focus(create ? "launcher-name" : "launcher-path");
|
ui.focus(create ? "launcher-name" : "launcher-path");
|
||||||
}
|
}
|
||||||
void set_mode(bool value) {
|
void set_mode(bool value) {
|
||||||
@@ -175,9 +178,9 @@ struct ProjectLauncher::Impl {
|
|||||||
const auto id = "launcher-recent-" + std::to_string(i++);
|
const auto id = "launcher-recent-" + std::to_string(i++);
|
||||||
auto& item = list.add(Kind::TreeRow, id, selection.name);
|
auto& item = list.add(Kind::TreeRow, id, selection.name);
|
||||||
item.layout.height = 40;
|
item.layout.height = 40;
|
||||||
item.tooltip = utf8(selection.path);
|
item.tooltip = path_to_utf8(selection.path);
|
||||||
item.on_click = [this, path = selection.path](Widget&) {
|
item.on_click = [this, path = selection.path](Widget&) {
|
||||||
ui.update_text("launcher-path", utf8(path), true);
|
ui.update_text("launcher-path", path_to_utf8(path), true);
|
||||||
set_mode(false);
|
set_mode(false);
|
||||||
};
|
};
|
||||||
} catch (...) {
|
} catch (...) {
|
||||||
@@ -250,7 +253,7 @@ struct ProjectLauncher::Impl {
|
|||||||
actions.layout.height = 36;
|
actions.layout.height = 36;
|
||||||
button(actions, "browser-choose", "Choose directory", [this] {
|
button(actions, "browser-choose", "Choose directory", [this] {
|
||||||
if (browser_valid) {
|
if (browser_valid) {
|
||||||
ui.update_text("launcher-path", utf8(browse_path), true);
|
ui.update_text("launcher-path", path_to_utf8(browse_path), true);
|
||||||
close_browser();
|
close_browser();
|
||||||
}
|
}
|
||||||
}).layout.width = 180;
|
}).layout.width = 180;
|
||||||
@@ -277,11 +280,11 @@ struct ProjectLauncher::Impl {
|
|||||||
auto& list = *ui.find("browser-list");
|
auto& list = *ui.find("browser-list");
|
||||||
list.children.clear();
|
list.children.clear();
|
||||||
list.scroll_y = 0;
|
list.scroll_y = 0;
|
||||||
ui.update_text("browser-path", utf8(browse_path), true);
|
ui.update_text("browser-path", path_to_utf8(browse_path), true);
|
||||||
std::size_t index = 0;
|
std::size_t index = 0;
|
||||||
for (const auto& child : children) {
|
for (const auto& child : children) {
|
||||||
auto& row = list.add(Kind::TreeRow, "browser-entry-" + std::to_string(index++),
|
auto& row = list.add(Kind::TreeRow, "browser-entry-" + std::to_string(index++),
|
||||||
"[Folder] " + utf8(child.filename()));
|
"[Folder] " + path_to_utf8(child.filename()));
|
||||||
row.layout.height = 34;
|
row.layout.height = 34;
|
||||||
row.on_click = [this, child](Widget&) {
|
row.on_click = [this, child](Widget&) {
|
||||||
ui.clear_focus();
|
ui.clear_focus();
|
||||||
@@ -327,6 +330,7 @@ struct ProjectLauncher::Impl {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
void refresh() {
|
void refresh() {
|
||||||
|
ui_scale = std::clamp(renderer.display_scale(), .5f, 4.f);
|
||||||
ui.find("launcher-open")->selected = !create;
|
ui.find("launcher-open")->selected = !create;
|
||||||
ui.find("launcher-create")->selected = create;
|
ui.find("launcher-create")->selected = create;
|
||||||
ui.find("launcher-2d")->selected = dimension == 2;
|
ui.find("launcher-2d")->selected = dimension == 2;
|
||||||
@@ -344,17 +348,17 @@ struct ProjectLauncher::Impl {
|
|||||||
ui.find("launcher-body")->enabled = !browsing;
|
ui.find("launcher-body")->enabled = !browsing;
|
||||||
auto* dialog = ui.find("launcher-browser");
|
auto* dialog = ui.find("launcher-browser");
|
||||||
dialog->visible = browsing;
|
dialog->visible = browsing;
|
||||||
dialog->layout.width = std::max(320.f, std::min(760.f, float(renderer.width()) - 40));
|
dialog->layout.width = std::max(320.f, std::min(760.f, logical_width() - 40));
|
||||||
dialog->layout.height = std::max(300.f, std::min(540.f, float(renderer.height()) - 40));
|
dialog->layout.height = std::max(300.f, std::min(540.f, logical_height() - 40));
|
||||||
dialog->layout.x = (float(renderer.width()) - dialog->layout.width) * .5f;
|
dialog->layout.x = (logical_width() - dialog->layout.width) * .5f;
|
||||||
dialog->layout.y = (float(renderer.height()) - dialog->layout.height) * .5f;
|
dialog->layout.y = (logical_height() - dialog->layout.height) * .5f;
|
||||||
ui.find("launcher-sidebar")->layout.width =
|
ui.find("launcher-sidebar")->layout.width =
|
||||||
std::clamp(float(renderer.width()) * .24f, 180.f, 235.f);
|
std::clamp(logical_width() * .24f, 180.f, 235.f);
|
||||||
ui.find("launcher-main")->layout.padding = renderer.width() < 850 ? 16 : 32;
|
ui.find("launcher-main")->layout.padding = logical_width() < 850 ? 16 : 32;
|
||||||
}
|
}
|
||||||
void frame(const std::vector<render::Event>& events) {
|
void frame(const std::vector<render::Event>& events) {
|
||||||
refresh();
|
refresh();
|
||||||
ui.layout(float(renderer.width()), float(renderer.height()));
|
ui.layout(float(renderer.width()), float(renderer.height()), ui_scale);
|
||||||
for (const auto& event : events) {
|
for (const auto& event : events) {
|
||||||
if (event.type == render::Event::Type::Quit)
|
if (event.type == render::Event::Type::Quit)
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
@@ -371,7 +375,7 @@ struct ProjectLauncher::Impl {
|
|||||||
ui.clear_focus();
|
ui.clear_focus();
|
||||||
navigate_pending();
|
navigate_pending();
|
||||||
if (!ui.editing() && browser_valid) {
|
if (!ui.editing() && browser_valid) {
|
||||||
ui.update_text("launcher-path", utf8(browse_path), true);
|
ui.update_text("launcher-path", path_to_utf8(browse_path), true);
|
||||||
close_browser();
|
close_browser();
|
||||||
}
|
}
|
||||||
} else
|
} else
|
||||||
@@ -384,10 +388,10 @@ struct ProjectLauncher::Impl {
|
|||||||
ui.handle(event);
|
ui.handle(event);
|
||||||
navigate_pending();
|
navigate_pending();
|
||||||
refresh();
|
refresh();
|
||||||
ui.layout(float(renderer.width()), float(renderer.height()));
|
ui.layout(float(renderer.width()), float(renderer.height()), ui_scale);
|
||||||
}
|
}
|
||||||
refresh();
|
refresh();
|
||||||
ui.layout(float(renderer.width()), float(renderer.height()));
|
ui.layout(float(renderer.width()), float(renderer.height()), ui_scale);
|
||||||
frame_data = {};
|
frame_data = {};
|
||||||
frame_data.clear_color = ui.theme().background;
|
frame_data.clear_color = ui.theme().background;
|
||||||
ui.draw(frame_data);
|
ui.draw(frame_data);
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
#include <iostream>
|
#include <iostream>
|
||||||
#include <stdexcept>
|
#include <stdexcept>
|
||||||
|
|
||||||
int main(int argc, char** argv) {
|
int schema_main(int argc, char** argv) {
|
||||||
try {
|
try {
|
||||||
std::filesystem::path output;
|
std::filesystem::path output;
|
||||||
for (int i = 1; i < argc; ++i) {
|
for (int i = 1; i < argc; ++i) {
|
||||||
@@ -14,7 +14,7 @@ int main(int argc, char** argv) {
|
|||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
if (argument == "--output" && i + 1 < argc && output.empty())
|
if (argument == "--output" && i + 1 < argc && output.empty())
|
||||||
output = argv[++i];
|
output = faset::path_from_utf8(argv[++i]);
|
||||||
else
|
else
|
||||||
throw std::invalid_argument("Unknown, repeated or incomplete argument: " +
|
throw std::invalid_argument("Unknown, repeated or incomplete argument: " +
|
||||||
argument);
|
argument);
|
||||||
@@ -33,3 +33,13 @@ int main(int argc, char** argv) {
|
|||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#ifdef _WIN32
|
||||||
|
int wmain(int argc, wchar_t** argv) {
|
||||||
|
return faset::run_utf8_main(argc, argv, schema_main);
|
||||||
|
}
|
||||||
|
#else
|
||||||
|
int main(int argc, char** argv) {
|
||||||
|
return schema_main(argc, argv);
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|||||||
@@ -4,6 +4,15 @@ if(TARGET faset_ui AND TARGET faset_editor_session AND TARGET faset_scene_view)
|
|||||||
add_library(faset_project_launcher STATIC ${PROJECT_SOURCE_DIR}/apps/project_launcher.cpp)
|
add_library(faset_project_launcher STATIC ${PROJECT_SOURCE_DIR}/apps/project_launcher.cpp)
|
||||||
target_link_libraries(faset_project_launcher PUBLIC faset_ui faset_render)
|
target_link_libraries(faset_project_launcher PUBLIC faset_ui faset_render)
|
||||||
if(BUILD_TESTING)
|
if(BUILD_TESTING)
|
||||||
|
add_executable(faset_editor_import_conflicts_ui_tests ${PROJECT_SOURCE_DIR}/tests/editor_ui_import_conflicts.cpp)
|
||||||
|
target_link_libraries(faset_editor_import_conflicts_ui_tests PRIVATE faset_editor_ui)
|
||||||
|
target_compile_definitions(faset_editor_import_conflicts_ui_tests PRIVATE FASET_TEST_ENGINE="${PROJECT_SOURCE_DIR}")
|
||||||
|
add_test(NAME editor_ui_import_conflicts COMMAND faset_editor_import_conflicts_ui_tests)
|
||||||
|
set_tests_properties(editor_ui_import_conflicts PROPERTIES LABELS "gpu")
|
||||||
|
# Invoked by verify_blender_roundtrip.py after real Blender generates its fixture.
|
||||||
|
add_executable(faset_blender_editor_probe ${PROJECT_SOURCE_DIR}/tests/editor_blender_reimport_probe.cpp)
|
||||||
|
target_link_libraries(faset_blender_editor_probe PRIVATE faset_editor_ui)
|
||||||
|
target_compile_definitions(faset_blender_editor_probe PRIVATE FASET_TEST_ENGINE="${PROJECT_SOURCE_DIR}")
|
||||||
add_executable(faset_editor_project_settings_ui_tests ${PROJECT_SOURCE_DIR}/tests/editor_ui_project_settings.cpp)
|
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_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}")
|
target_compile_definitions(faset_editor_project_settings_ui_tests PRIVATE FASET_TEST_ENGINE="${PROJECT_SOURCE_DIR}")
|
||||||
|
|||||||
@@ -20,5 +20,19 @@ if(TARGET faset_runtime AND TARGET faset_render AND TARGET faset_assets)
|
|||||||
add_executable(faset_player_tests ${PROJECT_SOURCE_DIR}/tests/runtime_player_tests.cpp)
|
add_executable(faset_player_tests ${PROJECT_SOURCE_DIR}/tests/runtime_player_tests.cpp)
|
||||||
target_link_libraries(faset_player_tests PRIVATE faset_scene_view faset_runtime faset_assets)
|
target_link_libraries(faset_player_tests PRIVATE faset_scene_view faset_runtime faset_assets)
|
||||||
add_test(NAME player_scene_contracts COMMAND faset_player_tests)
|
add_test(NAME player_scene_contracts COMMAND faset_player_tests)
|
||||||
|
|
||||||
|
# Exercise the production Player entry with an isolated throwing module.
|
||||||
|
# No test-only switches or behaviors are added to the shipping Player.
|
||||||
|
add_executable(faset_player_diagnostics
|
||||||
|
${PROJECT_SOURCE_DIR}/apps/player_main.cpp
|
||||||
|
${PROJECT_SOURCE_DIR}/tests/player_diagnostics/Gameplay.cpp)
|
||||||
|
target_include_directories(faset_player_diagnostics PRIVATE
|
||||||
|
${PROJECT_SOURCE_DIR}/tests/player_diagnostics)
|
||||||
|
target_link_libraries(faset_player_diagnostics PRIVATE faset_scene_view faset_runtime)
|
||||||
|
find_package(Python3 COMPONENTS Interpreter REQUIRED)
|
||||||
|
add_test(NAME player_shutdown_diagnostics COMMAND ${Python3_EXECUTABLE}
|
||||||
|
${PROJECT_SOURCE_DIR}/tests/player_diagnostics_test.py
|
||||||
|
$<TARGET_FILE:faset_player_diagnostics>)
|
||||||
|
set_tests_properties(player_shutdown_diagnostics PROPERTIES LABELS "gpu" TIMEOUT 60)
|
||||||
endif()
|
endif()
|
||||||
endif()
|
endif()
|
||||||
|
|||||||
@@ -35,6 +35,10 @@ if(BUILD_TESTING)
|
|||||||
add_test(NAME render_graph COMMAND faset_render_tests --unit)
|
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")
|
add_test(NAME render_offscreen COMMAND faset_render_tests --gpu "${CMAKE_BINARY_DIR}/render-test.ppm")
|
||||||
set_tests_properties(render_offscreen PROPERTIES LABELS "gpu")
|
set_tests_properties(render_offscreen PROPERTIES LABELS "gpu")
|
||||||
|
add_executable(faset_render_sprite_tests "${PROJECT_SOURCE_DIR}/tests/render_sprite_tests.cpp")
|
||||||
|
target_link_libraries(faset_render_sprite_tests PRIVATE faset_render)
|
||||||
|
add_test(NAME render_sprite_alpha COMMAND faset_render_sprite_tests)
|
||||||
|
set_tests_properties(render_sprite_alpha PROPERTIES LABELS "gpu")
|
||||||
add_executable(faset_render_reload_tests "${PROJECT_SOURCE_DIR}/tests/render_reload_tests.cpp")
|
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_link_libraries(faset_render_reload_tests PRIVATE faset_render faset_core)
|
||||||
target_compile_definitions(faset_render_reload_tests PRIVATE
|
target_compile_definitions(faset_render_reload_tests PRIVATE
|
||||||
@@ -45,5 +49,9 @@ if(BUILD_TESTING)
|
|||||||
FASET_PYTHON_EXECUTABLE="${Python3_EXECUTABLE}")
|
FASET_PYTHON_EXECUTABLE="${Python3_EXECUTABLE}")
|
||||||
add_test(NAME render_shader_reload COMMAND faset_render_reload_tests)
|
add_test(NAME render_shader_reload COMMAND faset_render_reload_tests)
|
||||||
set_tests_properties(render_shader_reload PROPERTIES LABELS "gpu")
|
set_tests_properties(render_shader_reload PROPERTIES LABELS "gpu")
|
||||||
|
add_executable(faset_render_window_tests "${PROJECT_SOURCE_DIR}/tests/render_window_tests.cpp")
|
||||||
|
target_link_libraries(faset_render_window_tests PRIVATE faset_render SDL3::SDL3)
|
||||||
|
add_test(NAME render_window_lifecycle COMMAND faset_render_window_tests "${CMAKE_BINARY_DIR}/window-test")
|
||||||
|
set_tests_properties(render_window_lifecycle PROPERTIES LABELS "gpu;window" TIMEOUT 40 SKIP_RETURN_CODE 77)
|
||||||
endif()
|
endif()
|
||||||
install(FILES ${FASET_SHADER_OUTPUTS} DESTINATION shaders)
|
install(FILES ${FASET_SHADER_OUTPUTS} DESTINATION shaders)
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
# Include after the Faset targets have been declared. A source manifest is merged
|
||||||
|
# with CMake's generated application manifest by its MSVC/Ninja linker wrapper.
|
||||||
|
# External build tools have their own manifests and still need a compatible host.
|
||||||
|
if(WIN32)
|
||||||
|
get_property(_faset_targets DIRECTORY "${PROJECT_SOURCE_DIR}" PROPERTY BUILDSYSTEM_TARGETS)
|
||||||
|
foreach(_faset_target IN LISTS _faset_targets)
|
||||||
|
get_target_property(_faset_type "${_faset_target}" TYPE)
|
||||||
|
if(_faset_type STREQUAL "EXECUTABLE" AND _faset_target MATCHES "^faset_")
|
||||||
|
target_sources("${_faset_target}" PRIVATE "${PROJECT_SOURCE_DIR}/cmake/windows.manifest")
|
||||||
|
endif()
|
||||||
|
endforeach()
|
||||||
|
endif()
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||||
|
<assembly xmlns="urn:schemas-microsoft-com:asm.v1" manifestVersion="1.0">
|
||||||
|
<application xmlns="urn:schemas-microsoft-com:asm.v3">
|
||||||
|
<windowsSettings>
|
||||||
|
<longPathAware xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">true</longPathAware>
|
||||||
|
</windowsSettings>
|
||||||
|
</application>
|
||||||
|
</assembly>
|
||||||
@@ -57,6 +57,8 @@ Scene compiler переводит авторские данные в runtime и
|
|||||||
|
|
||||||
Retained UI получает состояние и уведомления от AuthoringService; состояние виджетов не заменяет состояние документа. Стили/layout отделены от команд; ключевые действия доступны из интерфейса и через editor API. Импорт, компиляция и cook не блокируют event loop. Необходимые текстовый ввод, focus, clipboard, DPI и платформенные поведения проверяются на обеих ОС как часть UI, а не считаются автоматически решёнными SDL3.
|
Retained UI получает состояние и уведомления от AuthoringService; состояние виджетов не заменяет состояние документа. Стили/layout отделены от команд; ключевые действия доступны из интерфейса и через editor API. Импорт, компиляция и cook не блокируют event loop. Необходимые текстовый ввод, focus, clipboard, DPI и платформенные поведения проверяются на обеих ОС как часть UI, а не считаются автоматически решёнными SDL3.
|
||||||
|
|
||||||
|
**UI-референс не является источником истины.** Сгенерированные изображения задают визуальное направление: тёмные поверхности, плотность, типографику и иерархию. Состав функций, состояние документов и поведение определяют эти контракты, PLAN и проверенные пользовательские сценарии. Референс можно менять под рабочий функционал.
|
||||||
|
|
||||||
## 6. Runtime ECS, поведение и игровой цикл — принято
|
## 6. Runtime ECS, поведение и игровой цикл — принято
|
||||||
|
|
||||||
EnTT используется для компонентов и запросов за типизированным API Faset. Простое поведение и массовая система обращаются к одному состоянию. Собственная ECS не разрабатывается на первом этапе; выбор EnTT не обещает большей скорости или дешёвой замены backend. Системы объявляют фазу, чтение/запись компонентов и внешних ресурсов, зависимости и диагностическое имя.
|
EnTT используется для компонентов и запросов за типизированным API Faset. Простое поведение и массовая система обращаются к одному состоянию. Собственная ECS не разрабатывается на первом этапе; выбор EnTT не обещает большей скорости или дешёвой замены backend. Системы объявляют фазу, чтение/запись компонентов и внешних ресурсов, зависимости и диагностическое имя.
|
||||||
@@ -99,7 +101,7 @@ Development и release Player содержат runtime, выбранные иг
|
|||||||
|
|
||||||
Первый путь доставки — самостоятельный каталог/архив игры и отдельно symbols. Windows и Linux собираются и проверяются собственными native workers. Clang/clang-cl не устраняют необходимость платформенных SDK/runtime; кросс-экспорт из Linux в Windows не считается готовой возможностью. Полностью побайтово воспроизводимый executable и стабильный ABI любых компиляторов не обещаются наличием build manifest.
|
Первый путь доставки — самостоятельный каталог/архив игры и отдельно symbols. Windows и Linux собираются и проверяются собственными native workers. Clang/clang-cl не устраняют необходимость платформенных SDK/runtime; кросс-экспорт из Linux в Windows не считается готовой возможностью. Полностью побайтово воспроизводимый executable и стабильный ABI любых компиляторов не обещаются наличием build manifest.
|
||||||
|
|
||||||
Критерии [плана](../PLAN.md) включают цикл сцена → Inspector → MCP authoring edit → Undo → save → build → отдельный Player, повторный импорт GLB с сохранением overrides, lifecycle/handle tests и запуск на обеих ОС без Editor/Blender/MCP/SDK. Эти проверки предстоит реализовать и выполнить.
|
Критерии [плана](../PLAN.md) включают цикл сцена → Inspector → MCP authoring edit → Undo → save → build → отдельный Player, повторный импорт GLB с сохранением overrides, lifecycle/handle tests и запуск на обеих ОС без Editor/Blender/MCP/SDK. Выполненные проверки и незакрытые условия перечислены в [журнале реализации](IMPLEMENTATION.md); наличие теста само по себе не означает прохождения на обеих ОС.
|
||||||
|
|
||||||
## История решений
|
## История решений
|
||||||
|
|
||||||
|
|||||||
@@ -141,3 +141,68 @@ building its pinned software driver. A review also identified Windows Unicode pa
|
|||||||
boundaries that must be corrected before cross-platform acceptance. Clean offline
|
boundaries that must be corrected before cross-platform acceptance. Clean offline
|
||||||
build verification, final performance baselines and the final acceptance record
|
build verification, final performance baselines and the final acceptance record
|
||||||
remain open; no MVP tag has been created.
|
remain open; no MVP tag has been created.
|
||||||
|
|
||||||
|
### Checks after checkpoint 3
|
||||||
|
|
||||||
|
- Public commit: `d834cfad67cd81d8c4998b90c16791361ca8c0f8`.
|
||||||
|
- Linux and Windows headless CI plus strict manual passed in GitHub run
|
||||||
|
[35295534027](https://github.com/emil28092005/Faset_Engine/actions/runs/35295534027).
|
||||||
|
- A clean committed source snapshot built all native targets with external networking
|
||||||
|
disabled; all 19 CPU tests passed. The recorded build/test phase took 174.03 s.
|
||||||
|
[Offline evidence](validation/offline-linux-2026-09-18.json) distinguishes prepared
|
||||||
|
system tools from source/dependency inputs. GPU/window checks remain separate.
|
||||||
|
- The first full Windows graphics job compiled the whole Editor successfully, then
|
||||||
|
failed creating a Vulkan instance with its CI software driver. Its CPU SceneView
|
||||||
|
test also reported a texture-loading error. These are acceptance failures under
|
||||||
|
investigation, not verified Windows graphics/export support.
|
||||||
|
|
||||||
|
## Checkpoint 4 — portability and acceptance hardening
|
||||||
|
|
||||||
|
Implemented after concrete regression findings:
|
||||||
|
|
||||||
|
- UTF-8 project, resource, plugin, CLI and subprocess boundaries. Windows native paths
|
||||||
|
stay wide internally; Editor/Player/SchemaExporter normalize `wmain` arguments.
|
||||||
|
File reads, hashes and atomic writes use extended native paths. All Faset Windows
|
||||||
|
executables declare long-path support; CI enables the documented developer policy.
|
||||||
|
- Normal Stop requests Player shutdown before bounded termination fallback. Real
|
||||||
|
`onDestroy` execution was observed, and a dedicated Player fixture verifies that
|
||||||
|
callback diagnostics also reach logs during shutdown. Custom component versions
|
||||||
|
are checked against the linked gameplay schema, including data-only components.
|
||||||
|
- POSIX subprocess cleanup keeps the exited leader's PID reserved while terminating
|
||||||
|
its owned process group, then reaps it. A regression verifies that a descendant
|
||||||
|
ignoring SIGTERM cannot survive normal leader exit or cancellation, without losing
|
||||||
|
the leader's stdout tail or signaling a reused process-group ID.
|
||||||
|
- Transparent sprites use stable layer/depth ordering without depth writes, avoiding
|
||||||
|
invisible sprites hiding objects behind them. GPU regressions cover alpha layering,
|
||||||
|
shader/capture paths longer than 300 characters, and window capture after resize.
|
||||||
|
- Editor/launcher DPI scaling, logical-unit layout persistence and safe cancellation
|
||||||
|
of unfinished drags on scale changes. Retained UI tests cover 1×/2× text rasterization,
|
||||||
|
clipping, hit testing and text-input rectangles.
|
||||||
|
- Manual import-removal review with exact IDs/names and explicit acceptance. Both the
|
||||||
|
reviewed candidate and active generation are checked before publishing, so stale
|
||||||
|
confirmation cannot apply a different import. The GUI regression exercises rejection,
|
||||||
|
fresh review and successful acceptance through actual widget events.
|
||||||
|
- Extended template workflow now saves through UI controls and opens a new Session,
|
||||||
|
comparing IDs, nested origins, local additions and resolved values.
|
||||||
|
- Blender verification now updates two instances in one live Editor/SceneView, preserves
|
||||||
|
placement, tint, physics and opaque gameplay fields, and retains both visible meshes
|
||||||
|
after a removal conflict. [Blender evidence](validation/blender-live-linux-2026-09-18.json).
|
||||||
|
|
||||||
|
Observed Linux integration: **32 passed, 1 skipped, 0 failed** in the 33-test suite.
|
||||||
|
The skip is native Wayland programmatic restore, which the compositor declined;
|
||||||
|
the same lifecycle scenario passed under XWayland. ASan/UBSan passed **17/17** tests.
|
||||||
|
Strict MkDocs passed. [Recovery evidence](validation/editor-recovery-linux-2026-09-18.json)
|
||||||
|
records actual Editor termination and failed save/import/build/Player scenarios.
|
||||||
|
[Release baseline](validation/linux-release-2026-09-18/README.md) records the two
|
||||||
|
relocated Unicode-path packages and 240-frame profiles with their scope and hashes.
|
||||||
|
|
||||||
|
Windows execution of these fixes remains a separate gate. The previous fast CPU run
|
||||||
|
isolated a 264-character cooked path failure; the new native-path fixes must pass on
|
||||||
|
Windows before that issue is considered closed there. Native OS IME composition and
|
||||||
|
moving between physical monitors still have less coverage than deterministic widget
|
||||||
|
and platform-boundary tests. No MVP tag has been created.
|
||||||
|
|
||||||
|
The final build-service review also reproduced a publication defect: an invalid
|
||||||
|
custom-field default can pass the build stage's shallow schema check before the
|
||||||
|
Editor rejects it. Full schema validation before publishing `last_build.json` is the
|
||||||
|
next bounded correction; checkpoint 4 does not claim this gate is already complete.
|
||||||
|
|||||||
@@ -28,6 +28,14 @@ MSVC-family builds use the dynamic CRT: `/MDd` in Debug, `/MD` otherwise. Physic
|
|||||||
Player, Editor and native plugins use compatible settings. Development and export
|
Player, Editor and native plugins use compatible settings. Development and export
|
||||||
have separate CMake directories, avoiding accidental Debug/Release mixing.
|
have separate CMake directories, avoiding accidental Debug/Release mixing.
|
||||||
|
|
||||||
|
Windows developer machines use the **Enable Win32 long paths** policy
|
||||||
|
(`LongPathsEnabled=1`). Faset executables carry `longPathAware` manifests. Engine
|
||||||
|
stream/hash/atomic-file boundaries also use wide extended paths, while serialized
|
||||||
|
project paths remain ordinary UTF-8. This does not change the independent path
|
||||||
|
support of CMake, Ninja, the compiler or Slang. CI records/enables the same host
|
||||||
|
profile; the build guide explains setup. A temporary path longer than 260 characters
|
||||||
|
exposed the original SceneView failure and now has dedicated regression coverage.
|
||||||
|
|
||||||
## Graphics profiles
|
## Graphics profiles
|
||||||
|
|
||||||
The baseline requires Vulkan 1.3 with dynamic rendering and synchronization2, a
|
The baseline requires Vulkan 1.3 with dynamic rendering and synchronization2, a
|
||||||
@@ -69,3 +77,9 @@ and graphics driver; they do not need this development toolchain.
|
|||||||
An offline build is recorded only after a clean build directory is configured and
|
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
|
built with network access disabled. A warm incremental build or archive checksum
|
||||||
verification alone is not that acceptance check.
|
verification alone is not that acceptance check.
|
||||||
|
|
||||||
|
Commit `d834cfad67cd81d8c4998b90c16791361ca8c0f8` passed this check on Linux: full
|
||||||
|
native build and 19 CPU tests in a new user/network namespace with no external
|
||||||
|
connectivity, using a `git archive` checkout and only prefetched inputs. See
|
||||||
|
[`validation/offline-linux-2026-09-18.json`](validation/offline-linux-2026-09-18.json).
|
||||||
|
Reproduce using `python3 tools/verify_offline_build.py --output .cache/offline-check`.
|
||||||
|
|||||||
@@ -74,9 +74,17 @@ mesh datablocks remain shared. Repair deliberately duplicated material IDs in
|
|||||||
Blender's Custom Properties. Save the `.blend` again.
|
Blender's Custom Properties. Save the `.blend` again.
|
||||||
|
|
||||||
Removing an exported output produces an import conflict. Faset keeps the old
|
Removing an exported output produces an import conflict. Faset keeps the old
|
||||||
generation active. Inspect the diagnostics and update affected references before
|
generation active. In **Jobs**, choose **Review removals** to open **Conflicts**.
|
||||||
explicitly importing with `allow_removed_outputs: true`. This accepts removal;
|
Review the removed stable IDs and their last-good names; **Copy removed IDs** helps
|
||||||
it does not automatically remap references. A normal GLB without persistent custom
|
locate references. Update affected references, then use **Accept reviewed removal**.
|
||||||
|
If source data or the active generation has changed since review, the acceptance
|
||||||
|
fails safely; use **Reimport / review again** and inspect the new result.
|
||||||
|
|
||||||
|
The same explicit API action uses `allow_removed_outputs: true`, with
|
||||||
|
`expected_generation` and `expected_active_generation` taken from the reviewed
|
||||||
|
job's `result.generation` and `result.previous_generation`. Use the same source and
|
||||||
|
settings. This accepts removal; it does not automatically remap references or delete
|
||||||
|
scene components. A normal GLB without persistent custom
|
||||||
IDs uses structural matching, which cannot guarantee identity after rename or
|
IDs uses structural matching, which cannot guarantee identity after rename or
|
||||||
restructuring.
|
restructuring.
|
||||||
|
|
||||||
@@ -118,3 +126,7 @@ For an end-to-end example, open `examples/projects/collect-3d`. Its exit arch in
|
|||||||
a `.blend`, reproducible Blender script and a published bundle. The repository's
|
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
|
`tools/verify_blender_roundtrip.py` runs the actual Blender helper and Editor imports
|
||||||
to check rename, geometry changes, removal conflicts and failure preservation.
|
to check rename, geometry changes, removal conflicts and failure preservation.
|
||||||
|
Pass `--ui-probe build/linux-debug/faset_blender_editor_probe` to also test two
|
||||||
|
instances in one live editor session: both receive new geometry while preserving
|
||||||
|
placement, tint, opaque gameplay fields and the authoring revision. This additional
|
||||||
|
probe requires Vulkan and produces before/after captures.
|
||||||
|
|||||||
@@ -80,9 +80,12 @@ and package execution tests; that is separate from physical GPU-driver testing.
|
|||||||
|
|
||||||
## Troubleshooting
|
## Troubleshooting
|
||||||
|
|
||||||
**Unknown component or schema version:** enable/register the missing runtime module
|
**Unknown component:** enable/register the missing runtime module and rebuild. A
|
||||||
and rebuild. The Editor preserves its data as opaque authoring fields, but export
|
data-only custom component still needs a schema; it does not need callbacks.
|
||||||
requires a matching runtime implementation.
|
|
||||||
|
**Schema version mismatch:** match the linked gameplay schema to the document or
|
||||||
|
apply an explicit data migration, then rebuild. The Editor preserves future or
|
||||||
|
missing component data as opaque fields; export requires a matching schema.
|
||||||
|
|
||||||
**Missing asset:** import the original source or Blender manifest again. Preserve its
|
**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
|
sidecar so the AssetId remains stable. A cache copied from another project is not a
|
||||||
|
|||||||
@@ -23,8 +23,9 @@ resolution, validation activation, fixed ticks and timestep. A bounded run advan
|
|||||||
one synthetic fixed timestep per frame; it does not reproduce a real-time input
|
one synthetic fixed timestep per frame; it does not reproduce a real-time input
|
||||||
session. Keep that distinction when comparing runs.
|
session. Keep that distinction when comparing runs.
|
||||||
|
|
||||||
Startup starts at `main()` and ends at the first completed frame. OS process loading
|
Startup starts at the Player application entry after platform argument normalization
|
||||||
before `main()` is excluded. Frame wall times exclude writing the final profile and
|
and ends at the first completed frame. OS process loading and Windows `wmain` UTF-8
|
||||||
|
argument conversion are excluded. Frame wall times exclude writing the final profile and
|
||||||
capture files. Simulation and scene-snapshot times are separate from the renderer
|
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
|
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
|
utilization**. GPU timestamps measure the submitted graphics work and can be null
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
# Editor workspace
|
# Editor workspace
|
||||||
|
|
||||||
|
The Editor follows the window's display scale. Text is rasterized at that scale;
|
||||||
|
panel dimensions are saved in logical units so the layout remains usable when
|
||||||
|
moving between displays. A scale change preserves focused text and cancels an
|
||||||
|
unfinished numeric, divider or gizmo drag without committing an edit.
|
||||||
|
|
||||||
The Editor edits saved scene data and previews it in a Vulkan viewport. **Play**
|
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
|
opens a separate Player process. Gameplay movement and spawned objects stay in that
|
||||||
process; stopping it leaves the authoring scene unchanged.
|
process; stopping it leaves the authoring scene unchanged.
|
||||||
|
|||||||
@@ -67,6 +67,21 @@ Use an x64 Visual Studio Developer shell with the Windows SDK, MSVC runtime libr
|
|||||||
LLVM `clang-cl`, Ninja, CMake, and the Vulkan SDK available. Then use the
|
LLVM `clang-cl`, Ninja, CMake, and the Vulkan SDK available. Then use the
|
||||||
`windows-debug` or `windows-release` presets.
|
`windows-debug` or `windows-release` presets.
|
||||||
|
|
||||||
|
Enable **Win32 long paths** on the Windows development machine before starting the
|
||||||
|
build shell. Faset's executable manifest declares long-path support, and its direct
|
||||||
|
file IO uses wide extended paths; external CMake/Ninja/compiler tools also need a
|
||||||
|
compatible host policy for deeply nested build/cache directories. An administrator
|
||||||
|
can enable the policy once in PowerShell:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
New-ItemProperty -Path 'HKLM:\SYSTEM\CurrentControlSet\Control\FileSystem' `
|
||||||
|
-Name LongPathsEnabled -Value 1 -PropertyType DWORD -Force
|
||||||
|
```
|
||||||
|
|
||||||
|
Open a new build shell afterwards; Windows may require a restart for existing
|
||||||
|
processes. See Microsoft's [long-path requirements](https://learn.microsoft.com/en-us/windows/win32/fileio/maximum-file-path-limitation).
|
||||||
|
Windows CI enables and records this developer profile explicitly.
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
py tools/fetch_slang.py
|
py tools/fetch_slang.py
|
||||||
cmake --preset windows-debug
|
cmake --preset windows-debug
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ Include `<faset/runtime/Runtime.hpp>` and use namespace `faset::runtime`. This i
|
|||||||
|
|
||||||
## Register behavior
|
## Register behavior
|
||||||
|
|
||||||
`void Runtime::registerBehavior(std::string componentType, Behavior behavior)` registers callbacks before scene loading. An empty or duplicate type, registration during a callback, and registration after entities have loaded are rejected.
|
`void Runtime::registerBehavior(std::string componentType, Behavior behavior)` registers callbacks before scene loading. An empty or duplicate type and registration while entities are present or a callback is running are rejected. An empty or cleared world can register additional types.
|
||||||
|
|
||||||
`Behavior::Callback` is `std::function<void(Runtime&, EntityHandle, double)>`. Assign it to any of `onStart`, `fixedUpdate`, `update`, `lateUpdate`, and `onDestroy`. Unassigned members do nothing. `Behavior::onCollision` instead accepts `(Runtime&, EntityHandle, const CollisionEvent&)`.
|
`Behavior::Callback` is `std::function<void(Runtime&, EntityHandle, double)>`. Assign it to any of `onStart`, `fixedUpdate`, `update`, `lateUpdate`, and `onDestroy`. Unassigned members do nothing. `Behavior::onCollision` instead accepts `(Runtime&, EntityHandle, const CollisionEvent&)`.
|
||||||
|
|
||||||
@@ -63,6 +63,13 @@ Immediate validation errors throw. Deferred failures are recorded in diagnostics
|
|||||||
|
|
||||||
`Runtime(RuntimeConfig = {})` constructs the controller. `load(const nlohmann::json&)` validates and prepares a scene, creates its entities, then calls initial callbacks. Invalid scene data leaves the preceding world intact. `clear()` destroys the current entities and invalidates their session handles.
|
`Runtime(RuntimeConfig = {})` constructs the controller. `load(const nlohmann::json&)` validates and prepares a scene, creates its entities, then calls initial callbacks. Invalid scene data leaves the preceding world intact. `clear()` destroys the current entities and invalidates their session handles.
|
||||||
|
|
||||||
|
Direct callers can include `<faset/runtime/schema.hpp>` and call
|
||||||
|
`validate_scene_schemas(scene, gameplaySchema)` before `load`. The Player does this
|
||||||
|
automatically: each custom TypeId/version must match its linked schema. Generic
|
||||||
|
`Runtime` accepts opaque custom data with positive versions, including data-only
|
||||||
|
components; built-ins use version 1. This helper verifies identity/version, without
|
||||||
|
automatic migrations or authoring-style custom-field constraint validation.
|
||||||
|
|
||||||
`FrameStats advance(double elapsedSeconds, InputState = {})` advances fixed ticks, frame callbacks, interpolation, and late callbacks. `singleStep(InputState = {})` advances one fixed tick. `setPaused(bool)` clears accumulated time and resets presentation history; `paused()` reports this local state. Do not call load/clear/advance recursively from a callback.
|
`FrameStats advance(double elapsedSeconds, InputState = {})` advances fixed ticks, frame callbacks, interpolation, and late callbacks. `singleStep(InputState = {})` advances one fixed tick. `setPaused(bool)` clears accumulated time and resets presentation history; `paused()` reports this local state. Do not call load/clear/advance recursively from a callback.
|
||||||
|
|
||||||
`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.
|
`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.
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ Read the callback from top to bottom:
|
|||||||
4. `transform` gives a pose copy. Multiplying metres per second by seconds gives a displacement in metres.
|
4. `transform` gives a pose copy. Multiplying metres per second by seconds gives a displacement in metres.
|
||||||
5. `setTransform` publishes the changed non-physical pose.
|
5. `setTransform` publishes the changed non-physical pose.
|
||||||
|
|
||||||
The `[](...) { ... }` expression is a C++ lambda: a function stored in `Behavior::update`. Empty brackets mean it captures no local variables. `registerBehavior` takes ownership of the callback object. Register before calling `load`; registration after a world has loaded is rejected.
|
The `[](...) { ... }` expression is a C++ lambda: a function stored in `Behavior::update`. Empty brackets mean it captures no local variables. `registerBehavior` takes ownership of the callback object. Register before calling `load`; registration while entities exist or a callback is running is rejected.
|
||||||
|
|
||||||
The `schema()` function describes editable configuration. It does not create a runtime object. `tutorial.move_x` is the stable `TypeId`; `speed` is a stable `FieldId` within that type. Keep these IDs when changing a display label. Changing a field's meaning or units needs an explicit data migration, not just a new label.
|
The `schema()` function describes editable configuration. It does not create a runtime object. `tutorial.move_x` is the stable `TypeId`; `speed` is a stable `FieldId` within that type. Keep these IDs when changing a display label. Changing a field's meaning or units needs an explicit data migration, not just a new label.
|
||||||
|
|
||||||
@@ -56,7 +56,10 @@ The example scene is a complete, loadable document:
|
|||||||
|
|
||||||
The sprite is visible because it has `faset.sprite`. It moves because it also has `tutorial.move_x`. The configuration field is `speed`; the type string must match the registration exactly. `rotation` uses radians, and the default coordinate system is Y-up.
|
The sprite is visible because it has `faset.sprite`. It moves because it also has `tutorial.move_x`. The configuration field is `speed`; the type string must match the registration exactly. `rotation` uses radians, and the default coordinate system is Y-up.
|
||||||
|
|
||||||
After your project's schema is exported and loaded by the Editor, the type can be described through the same authoring schema used by the Inspector. The direct Player command above is useful before building an Editor workflow around your component.
|
After **Build C++** succeeds in the Editor, choose **Add component** in the Inspector
|
||||||
|
and select the registered type. Its schema supplies editable fields, defaults and
|
||||||
|
constraints. The direct Player command above is useful for testing the same behavior
|
||||||
|
independently of an editor session.
|
||||||
|
|
||||||
## Make a change and verify it
|
## Make a change and verify it
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,13 @@ A gameplay directory contains `Gameplay.hpp` and `Gameplay.cpp`. It provides two
|
|||||||
|
|
||||||
The Player calls registration before loading a scene. The separate SchemaExporter calls `schema()` without creating a game world or running gameplay callbacks. The Editor reads the resulting declaration; it does not load the gameplay binary into the Editor process.
|
The Player calls registration before loading a scene. The separate SchemaExporter calls `schema()` without creating a game world or running gameplay callbacks. The Editor reads the resulting declaration; it does not load the gameplay binary into the Editor process.
|
||||||
|
|
||||||
|
Every custom component in a played scene must have a matching TypeId and exact
|
||||||
|
positive version in the linked gameplay `schema()`. The Player checks this before
|
||||||
|
loading the world, including `--validate`. A data-only custom component still needs
|
||||||
|
a schema, even when it registers no callbacks. Built-in TypeIds are reserved. The
|
||||||
|
Editor preserves missing or future component data for recovery, but the Player
|
||||||
|
rejects it until the corresponding module/schema is available or the data is migrated.
|
||||||
|
|
||||||
A scene object receives a behavior by containing a component whose `type` matches the string passed to `registerBehavior`. Registering a behavior does not attach it to every object. A component can contain data without having any callbacks.
|
A scene object receives a behavior by containing a component whose `type` matches the string passed to `registerBehavior`. Registering a behavior does not attach it to every object. A component can contain data without having any callbacks.
|
||||||
|
|
||||||
## Data, poses, and state
|
## Data, poses, and state
|
||||||
|
|||||||
@@ -10,6 +10,12 @@ Each ordinary callback receives `(Runtime& game, EntityHandle self, double delta
|
|||||||
|
|
||||||
`onDestroy` runs while that object's handle and allowed component data are still valid. Clean up subscriptions or external C++ state there. After removal, `valid(oldHandle)` returns false. Removing a behavior component also runs that component's `onDestroy`. Clearing or replacing a scene runs destruction callbacks; a new scene uses a new session identity.
|
`onDestroy` runs while that object's handle and allowed component data are still valid. Clean up subscriptions or external C++ state there. After removal, `valid(oldHandle)` returns false. Removing a behavior component also runs that component's `onDestroy`. Clearing or replacing a scene runs destruction callbacks; a new scene uses a new session identity.
|
||||||
|
|
||||||
|
Normal Player shutdown and the Editor's **Stop** request also run destruction callbacks.
|
||||||
|
Stop gives the Player a bounded grace period to finish. An unresponsive native callback
|
||||||
|
can force the Editor to terminate the process; no application can guarantee cleanup
|
||||||
|
callbacks after forced termination or a crash. Such fallback is reported in Console.
|
||||||
|
Exceptions caught from callbacks, including shutdown callbacks, appear in Player logs.
|
||||||
|
|
||||||
Registering a callback does not make captured pointers safe. A lambda that stores a reference to a stack variable in `registerGameplay` will outlive that variable. The [timed-despawn example](examples.md#spawn-and-destroy-on-safe-boundaries) uses shared ownership for captured state and removes each object's entry on destruction.
|
Registering a callback does not make captured pointers safe. A lambda that stores a reference to a stack variable in `registerGameplay` will outlive that variable. The [timed-despawn example](examples.md#spawn-and-destroy-on-safe-boundaries) uses shared ownership for captured state and removes each object's entry on destruction.
|
||||||
|
|
||||||
## One fixed tick
|
## One fixed tick
|
||||||
@@ -58,4 +64,4 @@ The Player reads this optional object from the scene document. The settings are
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
This is an excerpt, not a complete scene. The tutorial scenes contain complete examples. `fixed_delta` is seconds, gravity is metres per second squared, and substeps are solver subdivisions inside one fixed tick. These do not create additional gameplay callbacks. Invalid configuration is rejected before the Player starts. Editing these JSON settings is implemented; an Editor settings panel should only be relied on where the current UI exposes it.
|
This is an excerpt, not a complete scene. The tutorial scenes contain complete examples. `fixed_delta` is seconds, gravity is metres per second squared, and substeps are solver subdivisions inside one fixed tick. These do not create additional gameplay callbacks. Invalid configuration is rejected before the Player starts. The Editor's **Simulation** dialog edits these scene settings through normal Undo transactions; **Project settings** edits the project's name, dimension and start scene separately.
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
# Validation evidence
|
||||||
|
|
||||||
|
These files preserve bounded checks and their inputs. Each record states its source revision or working-tree limitation; a passing record does not certify later commits or every supported platform.
|
||||||
|
|
||||||
|
- [Linux offline build](offline-linux-2026-09-18.json): a fresh build from committed `d834cfad67cd81d8c4998b90c16791361ca8c0f8`, prefetched dependency inputs, and a network namespace without external connectivity. CPU tests ran; graphics execution is separate.
|
||||||
|
- [Linux Release export and rendering](linux-release-2026-09-18/README.md): two relocated playable games, Unicode paths, imported Blender geometry, raw frame profiles and exact package hashes. This measurement used an evolving working tree around `5ac4db4`, not a clean checkout of the current commit.
|
||||||
|
- [Live Blender reimport](blender-live-linux-2026-09-18.json): actual Blender 4.5.3 exports and two live Editor instances, preserving authoring/gameplay/physics while refreshing geometry.
|
||||||
|
- [Editor recovery](editor-recovery-linux-2026-09-18.json): killed Editor, explicit journal recovery, failed save/import/build and failed Player startup with last-good preservation.
|
||||||
|
- [Native window boundaries](native-window-linux-2026-09-18.json): XWayland lifecycle, isolated Unicode clipboard, SDL text-input rectangles and the explicit native Wayland restore limitation.
|
||||||
|
- [Linux native window and input boundaries](native-window-linux-2026-09-18.json): XWayland resize/minimize/restore and SDL text-input-area checks; isolated Xvfb Unicode clipboard roundtrip with restoration. Native Wayland restore remains unconfirmed; real OS IME composition was not tested.
|
||||||
|
|
||||||
|
The Linux records do not establish Windows correctness, physical Windows GPU performance, native Wayland restore support, or editor responsiveness under large workloads. Windows CI artifacts record their own commit and software Vulkan results. A skipped compositor operation is not a completed acceptance check.
|
||||||
|
|
||||||
|
Performance budgets below are proposals for P1 regression tracking, not completed release requirements. Repeat measurements on a controlled reference host and add representative content before treating them as engine-wide targets.
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"blender": "Blender 4.5.3 LTS",
|
||||||
|
"asset_id": "54c039e4-c19f-4365-a9f8-a38416ec6e3f",
|
||||||
|
"initial_generation": "499f254ce440ecb86118f2f6803f21013e763d35cfdba1c4356be7a457eec718",
|
||||||
|
"renamed_generation": "963e996b59ba11c2f9dd9eba51b89e7ae3f3dce6f177e4adaf5682479cb204a8",
|
||||||
|
"stable_output_ids": true,
|
||||||
|
"removed_output_conflict": true,
|
||||||
|
"failed_import_keeps_generation": true,
|
||||||
|
"authoring_preserved": true,
|
||||||
|
"live_editor": {
|
||||||
|
"conflict_keeps_visible_generation": true,
|
||||||
|
"instances": 2,
|
||||||
|
"live_geometry_updated": true,
|
||||||
|
"opaque_gameplay_preserved": true,
|
||||||
|
"physics_fields_preserved": true,
|
||||||
|
"placement_and_tint_preserved": true,
|
||||||
|
"status": "passed",
|
||||||
|
"validation_enabled": true,
|
||||||
|
"validation_errors": 0
|
||||||
|
},
|
||||||
|
"source_context": "Shared Linux checkpoint 4 working tree around 5ac4db4; not a clean-commit result",
|
||||||
|
"reproduce": "python3 tools/verify_blender_roundtrip.py --blender PATH --editor build/linux-debug/faset_editor --ui-probe build/linux-debug/faset_blender_editor_probe --output NEW_DIRECTORY"
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
{
|
||||||
|
"format": "faset.editor-recovery-verification",
|
||||||
|
"version": 1,
|
||||||
|
"status": "passed",
|
||||||
|
"checks": [
|
||||||
|
"acknowledged unsaved edit survives killed Editor and explicit recovery",
|
||||||
|
"failed save preserves live authoring and last saved file",
|
||||||
|
"failed Player process is reported without changing authoring data",
|
||||||
|
"Undo repairs the scene, then Play and Stop leave authoring unchanged",
|
||||||
|
"failed C++ build keeps prior schema and reports stale status",
|
||||||
|
"fixed C++ rebuild restores current schema status",
|
||||||
|
"failed import preserves authoring"
|
||||||
|
],
|
||||||
|
"logs": [
|
||||||
|
"build completed",
|
||||||
|
"Gameplay schema loaded",
|
||||||
|
"Play started in a separate Player process",
|
||||||
|
"Player failed: unsupported component version\n",
|
||||||
|
"Player exited with code 1",
|
||||||
|
"build completed",
|
||||||
|
"Gameplay schema loaded",
|
||||||
|
"Play started in a separate Player process",
|
||||||
|
"New round: collect the three gold cubes, then reach the green exit. E resets.\n{\"device\":\"NVIDIA GeForce RTX 2080 Ti\",\"dimension\":2,\"frames\":382,\"ticks\":103,\"validation_errors\":0}\n",
|
||||||
|
"Play stopped; authoring scene unchanged",
|
||||||
|
"build failed: Process exited with code 1; see job log; previous gameplay metadata remains available and is marked stale",
|
||||||
|
"build completed",
|
||||||
|
"Gameplay schema loaded",
|
||||||
|
"Asset import failed: Cannot read: /home/emil/Desktop/Faset_Engine/.cache/recovery-checkpoint3-v3/project/Assets/missing.glb\n"
|
||||||
|
],
|
||||||
|
"engine_revision": "d834cfad67cd81d8c4998b90c16791361ca8c0f8",
|
||||||
|
"execution_context": "Linux development desktop; Editor and source from the clean offline checkout; disposable sample project",
|
||||||
|
"scope": "Crash recovery and last-good preservation. Graceful OnDestroy is verified separately by the later Player shutdown regression."
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
# Linux Release export baseline — 2026-09-18
|
||||||
|
|
||||||
|
Both checked-in playable projects passed real Editor export, standalone CPU validation and 120 offscreen frames after relocation. Their disposable source-project paths were hidden during launch; no `--scene` or `--assets` override was supplied. Copied project and executable paths contain `Faset Café 世界`. The 3D export includes the Blender exit-arch asset.
|
||||||
|
|
||||||
|
This is a measurement of the shared working tree around `5ac4db438dae5ee28e5619e62cf8d6647beecd52` plus in-progress checkpoint 4 changes. It is **not an exact clean-commit result**. The two Release generations are `48ec8546-3750-463f-9c4c-f76c3b933256` (2D) and `17281e17-9f4f-44f0-8821-6e0071e9a45b` (3D). [Evidence](evidence.json) retains project input hashes and build fingerprints; [2D](collect-2d-manifest.json) and [3D](collect-3d-manifest.json) manifests retain the exact executable, cooked data and shader hashes.
|
||||||
|
|
||||||
|
The same relocated executables then recorded 240 frames each, sequentially, without physics debug drawing or player input. Hardware: Ryzen 7 1700, 32 GiB RAM, RTX 2080 Ti 11 GiB, NVIDIA 595.84; Ubuntu 26.04, kernel 7.0.0-31. Build: Clang 21.1.8, Release `-O3 -DNDEBUG`, Vulkan 1.3, 1280×720, actual Khronos validation enabled. Both runs had zero validation errors. The desktop remained active; this was not an isolated benchmark machine.
|
||||||
|
|
||||||
|
Raw [2D](collect-2d-profile-240.json) and [3D](collect-3d-profile-240.json) profiles include every completed frame, including the first, with nearest-rank percentiles. Times below are p50 / p95 milliseconds:
|
||||||
|
|
||||||
|
- 2D: total measured frame 2.234 / 2.684; renderer call 1.865 / 2.221; GPU 0.565 / 0.579; readback 0.510 / 0.633; simulation 0.201 / 0.258; snapshot 0.170 / 0.215.
|
||||||
|
- 3D: total measured frame 2.299 / 2.732; renderer call 1.884 / 2.180; GPU 0.588 / 0.600; readback 0.469 / 0.608; simulation 0.213 / 0.300; snapshot 0.181 / 0.257.
|
||||||
|
|
||||||
|
Live Vulkan allocations stayed at 15,750,288 bytes (2D) and 15,787,008 bytes (3D), about 15.02 and 15.06 MiB. Counts were 11 / 31 draws, 66 / 576 packed vertices per frame, and one fallback texture. A packed mesh can be drawn again for its shadow pass. These memory totals include aligned explicit allocations and exclude driver internals. First-frame startup from `main()` was 265.5 / 260.8 ms; OS loader time is excluded.
|
||||||
|
|
||||||
|
`render_call` includes GPU waits and synchronous readback; it is not CPU utilization. GPU timestamps cover submitted rendering. Frame timings exclude profile bookkeeping and final file writes. Synthetic 1/60-second simulation ticks run as fast as the offscreen loop allows. The resulting numbers are not display FPS or a real-time gameplay pacing test. Small sample scenes, one GPU, 240 frames and a static camera cannot establish scalability, a memory-leak guarantee, or broad performance claims. The imported 3D material also emits the recorded warning about unsupported material features in its manifest.
|
||||||
|
|
||||||
|
Proposed P1 budgets for these exact scenes on this reference-class Linux host, at the same resolution and validation/readback settings:
|
||||||
|
|
||||||
|
- Measured frame p95 ≤ 4 ms; GPU p95 ≤ 1 ms; readback p95 ≤ 1 ms.
|
||||||
|
- Simulation and snapshot p95 ≤ 0.5 ms each.
|
||||||
|
- Explicit live Vulkan memory ≤ 20 MiB, with no growth after warmup over a future 3,000-frame resource-lifecycle run.
|
||||||
|
- Startup from `main()` ≤ 500 ms for these tiny packages.
|
||||||
|
|
||||||
|
These initial thresholds leave headroom above the observed values. They are **proposed**, not enforced acceptance criteria. P1 should retain cold-start results separately, run repeated sessions, measure editor input latency and larger content, and collect independent Windows hardware baselines before adopting release gates.
|
||||||
|
|
||||||
|
Reproduce export validation from the repository with `python tools/verify_playable_exports.py --editor build/linux-debug/faset_editor --output <new-empty-directory>`. Run each retained standalone Player with `--headless --frames 240 --profile <output.json>` to repeat the longer measurement. A new run builds current sources and produces new immutable generations; use the stored hashes to distinguish it from this record.
|
||||||
|
|
||||||
|
Captured 120-frame outputs:
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|

|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
{
|
||||||
|
"asset_generations": {},
|
||||||
|
"build_fingerprint": "5b1996b0fe44eeaa01ee1fd581f9f063f8c84147128e896541d27207dbab9aaa",
|
||||||
|
"configuration": "Release",
|
||||||
|
"executable": "faset_player",
|
||||||
|
"files": [
|
||||||
|
{
|
||||||
|
"path": "scene.fscene",
|
||||||
|
"sha256": "811242b85ede88c6d7920eeda5b7f6ae189db4d9e17d568cdfd230f27ce629b6",
|
||||||
|
"size": 6496
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "faset_player",
|
||||||
|
"sha256": "ef12daa3a1e71ef9e9d933cfa0347249de86b5e8da3536e709a3899a731e4333",
|
||||||
|
"size": 5809704
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "shaders/vertexMain.spv",
|
||||||
|
"sha256": "1ad2631c35d654f48166321ae43d4165043e1b919b14f1dc61caf97b1ada0898",
|
||||||
|
"size": 1224
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "shaders/fragmentMain.spv",
|
||||||
|
"sha256": "f5c4f289917ecab5d00d543c053aac1d10e6cca311fd172daf06d315160a3505",
|
||||||
|
"size": 7436
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "shaders/shadowMain.spv",
|
||||||
|
"sha256": "7643b4d688492b5ee923b9606f0b0e70343ca05fa67673838206119a6f86d8d8",
|
||||||
|
"size": 784
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "shaders/vertexMain.reflection.json",
|
||||||
|
"sha256": "c584f3b957ebf194a292451362a98590f85ff3cfbfd8f917b1c1265039381777",
|
||||||
|
"size": 2639
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "shaders/fragmentMain.reflection.json",
|
||||||
|
"sha256": "72ebf8d32452830650416dc4717d89a4324e97ece7028dfb97c66803c8dce71f",
|
||||||
|
"size": 2649
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "shaders/shadowMain.reflection.json",
|
||||||
|
"sha256": "e3fd0889cd2f569d410f5d7558a4091d9f57f247a1dcf1788462f67525340819",
|
||||||
|
"size": 2645
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "Notices/sdl3/LICENSE.txt",
|
||||||
|
"sha256": "97f35b302b361680ec1e891e95d2d52097bb95abff361434916d99dc1305f127",
|
||||||
|
"size": 884
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "Notices/entt/LICENSE",
|
||||||
|
"sha256": "0785027ce472d7c61f05fba664a1d3ba6639c1593ced351ad9e4bed868765d99",
|
||||||
|
"size": 1097
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "Notices/box2d/LICENSE",
|
||||||
|
"sha256": "68a3e676d7e94093b102d5cba0d4e04af812040d6f230c3db67a6664574e43d2",
|
||||||
|
"size": 1067
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "Notices/box3d/LICENSE",
|
||||||
|
"sha256": "da5e31a26bf3cfd5ba5c96d6823e480128c81e76c107ef9d3ee5d94789184b90",
|
||||||
|
"size": 1067
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "Notices/json/LICENSE.MIT",
|
||||||
|
"sha256": "46a65cffd1ea955132d95a8dd921640714a8d6b537d2e4e482d31145ae95b603",
|
||||||
|
"size": 1076
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "Notices/stb/LICENSE",
|
||||||
|
"sha256": "bebfe904b14301657e4e5d655c811d51fd31b97c455b9cc2d8600d6bac6cff63",
|
||||||
|
"size": 2510
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "Notices/dependencies.json",
|
||||||
|
"sha256": "a54639ad2d9ad13c2e5f1ec90d6775f5dfc493656789b78c7e44debe2a378fdc",
|
||||||
|
"size": 2206
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "Notices/Faset-NOTICE.txt",
|
||||||
|
"sha256": "30c2e7e80f5153b31ab57675e07246aef9165965d64e36022b426a26449ece3f",
|
||||||
|
"size": 204
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "README.txt",
|
||||||
|
"sha256": "4dc2f0271c69fe39017ff96aa2dfa00b625bec1401d924dc396193ce71c09f3e",
|
||||||
|
"size": 225
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"format": "faset.export",
|
||||||
|
"generation": "48ec8546-3750-463f-9c4c-f76c3b933256",
|
||||||
|
"platform": "linux",
|
||||||
|
"prerequisites": [
|
||||||
|
"Linux x86_64",
|
||||||
|
"Vulkan 1.3 driver",
|
||||||
|
"Compatible glibc and libstdc++ runtime"
|
||||||
|
],
|
||||||
|
"renderer_profile": {
|
||||||
|
"api": "Vulkan 1.3",
|
||||||
|
"materials": [
|
||||||
|
"base-color factor and texture",
|
||||||
|
"metallic and roughness factors"
|
||||||
|
],
|
||||||
|
"required_features": [
|
||||||
|
"dynamicRendering",
|
||||||
|
"synchronization2"
|
||||||
|
],
|
||||||
|
"shadow_map": {
|
||||||
|
"resolution": 1024,
|
||||||
|
"world_extent": 40
|
||||||
|
},
|
||||||
|
"texture_sampling": "linear clamp, one mip level",
|
||||||
|
"unsupported_material_features": [
|
||||||
|
"normal maps",
|
||||||
|
"metallic-roughness maps",
|
||||||
|
"emissive and occlusion maps",
|
||||||
|
"alpha mode selection",
|
||||||
|
"unlit mode",
|
||||||
|
"per-material face culling"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"scene_hash": "05ab2c3e5b152ab25867976c47635961f440f41d8cd0408732eb2d8d5b7800a8",
|
||||||
|
"simulation": {
|
||||||
|
"fixed_delta": 0.016666666666666666,
|
||||||
|
"gravity": [
|
||||||
|
0,
|
||||||
|
-9.81,
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"max_catch_up_ticks": 4,
|
||||||
|
"physics_substeps": 4
|
||||||
|
},
|
||||||
|
"units": {
|
||||||
|
"angle": "radian",
|
||||||
|
"coordinates": "right-handed Y-up",
|
||||||
|
"distance": "metre"
|
||||||
|
},
|
||||||
|
"validation_log": "{\"dimension\":2,\"validated\":true}\n",
|
||||||
|
"version": 1
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
Binary file not shown.
|
After Width: | Height: | Size: 4.4 KiB |
@@ -0,0 +1,170 @@
|
|||||||
|
{
|
||||||
|
"asset_generations": {
|
||||||
|
"5832763b-3ed0-44d6-9088-0b524f196a91": "4a5bfcc1437a7b4d65c388da2beace0308cceda8e16503d08ce676260f21d5c2"
|
||||||
|
},
|
||||||
|
"build_fingerprint": "5a516f5c4bded8f8e3f027b3dd2812a34b7f40b37e281c21ce87d7586fff88e4",
|
||||||
|
"configuration": "Release",
|
||||||
|
"executable": "faset_player",
|
||||||
|
"files": [
|
||||||
|
{
|
||||||
|
"path": "scene.fscene",
|
||||||
|
"sha256": "a7ac2dcd93dc9dce2d8ce860aa5dd838af2604dd7b40d5b50581d1a72bd5bf23",
|
||||||
|
"size": 9218
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "faset_player",
|
||||||
|
"sha256": "a68c39af919d5c04db4985f36e3da4573f9a4c13862bc599cd85724ac6da22dc",
|
||||||
|
"size": 5814720
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "shaders/vertexMain.spv",
|
||||||
|
"sha256": "1ad2631c35d654f48166321ae43d4165043e1b919b14f1dc61caf97b1ada0898",
|
||||||
|
"size": 1224
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "shaders/fragmentMain.spv",
|
||||||
|
"sha256": "f5c4f289917ecab5d00d543c053aac1d10e6cca311fd172daf06d315160a3505",
|
||||||
|
"size": 7436
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "shaders/shadowMain.spv",
|
||||||
|
"sha256": "7643b4d688492b5ee923b9606f0b0e70343ca05fa67673838206119a6f86d8d8",
|
||||||
|
"size": 784
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "shaders/vertexMain.reflection.json",
|
||||||
|
"sha256": "c584f3b957ebf194a292451362a98590f85ff3cfbfd8f917b1c1265039381777",
|
||||||
|
"size": 2639
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "shaders/fragmentMain.reflection.json",
|
||||||
|
"sha256": "72ebf8d32452830650416dc4717d89a4324e97ece7028dfb97c66803c8dce71f",
|
||||||
|
"size": 2649
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "shaders/shadowMain.reflection.json",
|
||||||
|
"sha256": "e3fd0889cd2f569d410f5d7558a4091d9f57f247a1dcf1788462f67525340819",
|
||||||
|
"size": 2645
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "assets/5832763b-3ed0-44d6-9088-0b524f196a91/generations/4a5bfcc1437a7b4d65c388da2beace0308cceda8e16503d08ce676260f21d5c2/meshes/mesh-fc069a07a87e0f831fd92efdbc4efa1f-0.fmesh",
|
||||||
|
"sha256": "98b38aca7d2786c58a2265ba4d1f2255123f03d31d947e239232cf55bfdc2c97",
|
||||||
|
"size": 928
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "assets/5832763b-3ed0-44d6-9088-0b524f196a91/generations/4a5bfcc1437a7b4d65c388da2beace0308cceda8e16503d08ce676260f21d5c2/meshes/mesh-0961e2a3f92977836a3aa80cab09277e-0.fmesh",
|
||||||
|
"sha256": "98b38aca7d2786c58a2265ba4d1f2255123f03d31d947e239232cf55bfdc2c97",
|
||||||
|
"size": 928
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "assets/5832763b-3ed0-44d6-9088-0b524f196a91/generations/4a5bfcc1437a7b4d65c388da2beace0308cceda8e16503d08ce676260f21d5c2/meshes/mesh-93b5b3e52cb6bb120fefb80e2d8e222b-0.fmesh",
|
||||||
|
"sha256": "3875c16ea628d4126373ebfe07816621fbad3961596a2fcd112f1eec0e378071",
|
||||||
|
"size": 928
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "assets/5832763b-3ed0-44d6-9088-0b524f196a91/generations/4a5bfcc1437a7b4d65c388da2beace0308cceda8e16503d08ce676260f21d5c2/manifest.json",
|
||||||
|
"sha256": "ee3f68794c6dc44ab492b1ba4640aa74e4de79a5ec10aed8dcdd45a03afc508e",
|
||||||
|
"size": 4532
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "assets/5832763b-3ed0-44d6-9088-0b524f196a91/current.json",
|
||||||
|
"sha256": "b0732959808ce61ef5a3223fba2d9ad8c17a3ea7873d4a81c00aadee6c51032e",
|
||||||
|
"size": 134
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "Notices/sdl3/LICENSE.txt",
|
||||||
|
"sha256": "97f35b302b361680ec1e891e95d2d52097bb95abff361434916d99dc1305f127",
|
||||||
|
"size": 884
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "Notices/entt/LICENSE",
|
||||||
|
"sha256": "0785027ce472d7c61f05fba664a1d3ba6639c1593ced351ad9e4bed868765d99",
|
||||||
|
"size": 1097
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "Notices/box2d/LICENSE",
|
||||||
|
"sha256": "68a3e676d7e94093b102d5cba0d4e04af812040d6f230c3db67a6664574e43d2",
|
||||||
|
"size": 1067
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "Notices/box3d/LICENSE",
|
||||||
|
"sha256": "da5e31a26bf3cfd5ba5c96d6823e480128c81e76c107ef9d3ee5d94789184b90",
|
||||||
|
"size": 1067
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "Notices/json/LICENSE.MIT",
|
||||||
|
"sha256": "46a65cffd1ea955132d95a8dd921640714a8d6b537d2e4e482d31145ae95b603",
|
||||||
|
"size": 1076
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "Notices/stb/LICENSE",
|
||||||
|
"sha256": "bebfe904b14301657e4e5d655c811d51fd31b97c455b9cc2d8600d6bac6cff63",
|
||||||
|
"size": 2510
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "Notices/dependencies.json",
|
||||||
|
"sha256": "a54639ad2d9ad13c2e5f1ec90d6775f5dfc493656789b78c7e44debe2a378fdc",
|
||||||
|
"size": 2206
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "Notices/Faset-NOTICE.txt",
|
||||||
|
"sha256": "30c2e7e80f5153b31ab57675e07246aef9165965d64e36022b426a26449ece3f",
|
||||||
|
"size": 204
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "README.txt",
|
||||||
|
"sha256": "4dc2f0271c69fe39017ff96aa2dfa00b625bec1401d924dc396193ce71c09f3e",
|
||||||
|
"size": 225
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"format": "faset.export",
|
||||||
|
"generation": "17281e17-9f4f-44f0-8821-6e0071e9a45b",
|
||||||
|
"platform": "linux",
|
||||||
|
"prerequisites": [
|
||||||
|
"Linux x86_64",
|
||||||
|
"Vulkan 1.3 driver",
|
||||||
|
"Compatible glibc and libstdc++ runtime"
|
||||||
|
],
|
||||||
|
"renderer_profile": {
|
||||||
|
"api": "Vulkan 1.3",
|
||||||
|
"materials": [
|
||||||
|
"base-color factor and texture",
|
||||||
|
"metallic and roughness factors"
|
||||||
|
],
|
||||||
|
"required_features": [
|
||||||
|
"dynamicRendering",
|
||||||
|
"synchronization2"
|
||||||
|
],
|
||||||
|
"shadow_map": {
|
||||||
|
"resolution": 1024,
|
||||||
|
"world_extent": 40
|
||||||
|
},
|
||||||
|
"texture_sampling": "linear clamp, one mip level",
|
||||||
|
"unsupported_material_features": [
|
||||||
|
"normal maps",
|
||||||
|
"metallic-roughness maps",
|
||||||
|
"emissive and occlusion maps",
|
||||||
|
"alpha mode selection",
|
||||||
|
"unlit mode",
|
||||||
|
"per-material face culling"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"scene_hash": "6510f994ee01af5823614911fcdeff0fd433846d937ff1a65d5bf5fb08e45e8f",
|
||||||
|
"simulation": {
|
||||||
|
"fixed_delta": 0.016666666666666666,
|
||||||
|
"gravity": [
|
||||||
|
0,
|
||||||
|
-9.81,
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"max_catch_up_ticks": 4,
|
||||||
|
"physics_substeps": 4
|
||||||
|
},
|
||||||
|
"units": {
|
||||||
|
"angle": "radian",
|
||||||
|
"coordinates": "right-handed Y-up",
|
||||||
|
"distance": "metre"
|
||||||
|
},
|
||||||
|
"validation_log": "warning: material material-bb63b094febbf90bb7049c9bcf7587bc has features beyond the initial base-color/PBR renderer\n{\"dimension\":3,\"validated\":true}\n",
|
||||||
|
"version": 1
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
Binary file not shown.
|
After Width: | Height: | Size: 20 KiB |
@@ -0,0 +1,312 @@
|
|||||||
|
{
|
||||||
|
"format": "faset.validation-evidence",
|
||||||
|
"version": 1,
|
||||||
|
"status": "passed",
|
||||||
|
"scope": "Linux relocated Release game export and offscreen small-scene measurement",
|
||||||
|
"source_checkout": {
|
||||||
|
"reference_commit": "5ac4db438dae5ee28e5619e62cf8d6647beecd52",
|
||||||
|
"clean_commit_match": false,
|
||||||
|
"description": "Shared working tree around 5ac4db4 plus in-progress checkpoint 4 work; builds were not made from a frozen clean commit. Exact exported binary/shader hashes are retained in each manifest."
|
||||||
|
},
|
||||||
|
"export_started_utc": "2026-09-18T01:46:06.267995+00:00",
|
||||||
|
"export_finished_utc": "2026-09-18T01:49:50.149934+00:00",
|
||||||
|
"host": {
|
||||||
|
"os": "Ubuntu 26.04 LTS",
|
||||||
|
"kernel": "7.0.0-31-generic x86_64",
|
||||||
|
"cpu": "AMD Ryzen 7 1700 Eight-Core Processor (8 cores,16 threads)",
|
||||||
|
"system_memory_bytes": 33571037184,
|
||||||
|
"gpu": "NVIDIA GeForce RTX 2080 Ti",
|
||||||
|
"gpu_memory_mib": 11264,
|
||||||
|
"nvidia_driver": "595.84"
|
||||||
|
},
|
||||||
|
"configuration": {
|
||||||
|
"build": "Release",
|
||||||
|
"compiler": "Clang 21.1.8",
|
||||||
|
"cxx_flags": "-O3 -DNDEBUG",
|
||||||
|
"cmake": "4.2.3",
|
||||||
|
"api": "Vulkan 1.3",
|
||||||
|
"resolution": [
|
||||||
|
1280,
|
||||||
|
720
|
||||||
|
],
|
||||||
|
"presentation": "offscreen",
|
||||||
|
"validation_enabled": true,
|
||||||
|
"physics_debug": false,
|
||||||
|
"frames_per_final_profile": 240,
|
||||||
|
"fixed_delta_seconds": 0.016666666666666666,
|
||||||
|
"simulation": "synthetic fixed timestep, no player input",
|
||||||
|
"sampling": "nearest rank, all completed frames, cold first frame retained"
|
||||||
|
},
|
||||||
|
"verification": {
|
||||||
|
"tool": "tools/verify_playable_exports.py",
|
||||||
|
"project_copies_and_relocated_binaries_include": "Faset Café 世界",
|
||||||
|
"steps": [
|
||||||
|
"Copy exact checked-in sample project inputs",
|
||||||
|
"Real Editor import of 3D Blender arch bundle",
|
||||||
|
"Real Editor Release export",
|
||||||
|
"Verify every package file size and SHA256",
|
||||||
|
"Copy generation outside engine/source trees",
|
||||||
|
"Hide disposable source-project paths",
|
||||||
|
"Launch Player from unrelated empty cwd without scene/assets arguments",
|
||||||
|
"CPU --validate then 120-frame offscreen capture and profile",
|
||||||
|
"Additional 240-frame no-debug profile of each same relocated executable"
|
||||||
|
],
|
||||||
|
"validation_errors": 0
|
||||||
|
},
|
||||||
|
"projects": [
|
||||||
|
{
|
||||||
|
"name": "collect-2d",
|
||||||
|
"dimension": 2,
|
||||||
|
"generation": "48ec8546-3750-463f-9c4c-f76c3b933256",
|
||||||
|
"build_fingerprint": "5b1996b0fe44eeaa01ee1fd581f9f063f8c84147128e896541d27207dbab9aaa",
|
||||||
|
"scene_hash": "05ab2c3e5b152ab25867976c47635961f440f41d8cd0408732eb2d8d5b7800a8",
|
||||||
|
"asset_generations": {},
|
||||||
|
"source_inputs": [
|
||||||
|
{
|
||||||
|
"path": ".gitignore",
|
||||||
|
"sha256": "53aa4d7124d4c8d93b8d7c4cb470b6c82b844e019a660d6243ffeadc50602de1"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "README.md",
|
||||||
|
"sha256": "d6c35206b10ee28efd5cc02da77a3ce9b6e1f8ce085793150bbf2f23bd925d65"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "Scenes/main.scene.json",
|
||||||
|
"sha256": "be48a6d21385e717b6c8ebf9a3b4322eebbeff6bb067428e089ab5214be79854"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "Scripts/Gameplay.cpp",
|
||||||
|
"sha256": "6b581b24da613e1915819de0a0f0ea820dd3333efa62c4b479a9ea7ffdbc6bcc"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "Scripts/Gameplay.hpp",
|
||||||
|
"sha256": "4121587182ed4f74901b1c0757c53daadbf309af1d6bb5dd87fef142130ccbbe"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "project.faset.json",
|
||||||
|
"sha256": "36e2acd32210f5489372a2331346a3294ead2395f0713e3e5fd2da4e83263689"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"package_file_count": 17,
|
||||||
|
"source_project_paths_unavailable": true,
|
||||||
|
"standalone_validation": "passed",
|
||||||
|
"standalone_render_frames": 120,
|
||||||
|
"capture": {
|
||||||
|
"width": 1280,
|
||||||
|
"height": 720,
|
||||||
|
"sampled_colors": 7,
|
||||||
|
"sha256": "f9d74067d87c1ebf30c6db372a26d93b7285791c8cb0307a24af5adb23bf201a"
|
||||||
|
},
|
||||||
|
"final_profile": "collect-2d-profile-240.json",
|
||||||
|
"profile_recorded_at_utc": "2026-09-18T01:51:24.599491+00:00",
|
||||||
|
"gpu_allocated_bytes": {
|
||||||
|
"min": 15750288,
|
||||||
|
"max": 15750288
|
||||||
|
},
|
||||||
|
"draw_calls": [
|
||||||
|
11
|
||||||
|
],
|
||||||
|
"vertices": [
|
||||||
|
66
|
||||||
|
],
|
||||||
|
"texture_count": [
|
||||||
|
1
|
||||||
|
],
|
||||||
|
"summary_ms": {
|
||||||
|
"gpu": {
|
||||||
|
"max": 0.58912,
|
||||||
|
"min": 0.563616,
|
||||||
|
"p50": 0.564832,
|
||||||
|
"p95": 0.57856,
|
||||||
|
"samples": 240
|
||||||
|
},
|
||||||
|
"render_call": {
|
||||||
|
"max": 5.705444,
|
||||||
|
"min": 1.581003,
|
||||||
|
"p50": 1.865269,
|
||||||
|
"p95": 2.22086,
|
||||||
|
"samples": 240
|
||||||
|
},
|
||||||
|
"renderer_cpu": {
|
||||||
|
"max": 5.701056,
|
||||||
|
"min": 1.578639,
|
||||||
|
"p50": 1.861723,
|
||||||
|
"p95": 2.216031,
|
||||||
|
"samples": 240
|
||||||
|
},
|
||||||
|
"renderer_readback_cpu": {
|
||||||
|
"max": 2.763424,
|
||||||
|
"min": 0.432917,
|
||||||
|
"p50": 0.509912,
|
||||||
|
"p95": 0.632894,
|
||||||
|
"samples": 240
|
||||||
|
},
|
||||||
|
"simulation": {
|
||||||
|
"max": 0.513789,
|
||||||
|
"min": 0.144743,
|
||||||
|
"p50": 0.200708,
|
||||||
|
"p95": 0.258247,
|
||||||
|
"samples": 240
|
||||||
|
},
|
||||||
|
"snapshot": {
|
||||||
|
"max": 0.25444,
|
||||||
|
"min": 0.127029,
|
||||||
|
"p50": 0.170341,
|
||||||
|
"p95": 0.214946,
|
||||||
|
"samples": 240
|
||||||
|
},
|
||||||
|
"wall": {
|
||||||
|
"max": 6.089358,
|
||||||
|
"min": 1.879436,
|
||||||
|
"p50": 2.233955,
|
||||||
|
"p95": 2.684014,
|
||||||
|
"samples": 240
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "collect-3d",
|
||||||
|
"dimension": 3,
|
||||||
|
"generation": "17281e17-9f4f-44f0-8821-6e0071e9a45b",
|
||||||
|
"build_fingerprint": "5a516f5c4bded8f8e3f027b3dd2812a34b7f40b37e281c21ce87d7586fff88e4",
|
||||||
|
"scene_hash": "6510f994ee01af5823614911fcdeff0fd433846d937ff1a65d5bf5fb08e45e8f",
|
||||||
|
"asset_generations": {
|
||||||
|
"5832763b-3ed0-44d6-9088-0b524f196a91": "4a5bfcc1437a7b4d65c388da2beace0308cceda8e16503d08ce676260f21d5c2"
|
||||||
|
},
|
||||||
|
"source_inputs": [
|
||||||
|
{
|
||||||
|
"path": ".gitignore",
|
||||||
|
"sha256": "53aa4d7124d4c8d93b8d7c4cb470b6c82b844e019a660d6243ffeadc50602de1"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "Assets/exit-arch/create.py",
|
||||||
|
"sha256": "9c7291124fb1498a3f992ef74afc39cd98f9cc3e2ff4687c633bece85d580905"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "Assets/exit-arch/manifest.json",
|
||||||
|
"sha256": "7a13347adc89761fb02ebeab3c58ba87f319cd62f53ec87a3bb61bcd066ffa55"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "Assets/exit-arch/manifest.json.faset-import.json",
|
||||||
|
"sha256": "ef4eef28a18a0cc40a40df75db34c97faa5d783b891265f862633f3aec514afc"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "Assets/exit-arch/payload/ee6c748a212905fc9c465cbe95c033e94b80be643624f4f240ad5353761205b3.glb",
|
||||||
|
"sha256": "ee6c748a212905fc9c465cbe95c033e94b80be643624f4f240ad5353761205b3"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "Assets/exit-arch/source.blend",
|
||||||
|
"sha256": "ff2676bec97ab778e531e87a185ad716049ea3b05b4a4f0027a6345737735035"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "README.md",
|
||||||
|
"sha256": "2df5ca4df47ab5d3d05d41508201296d4ad5ec876245f19f061767ade6a30e8f"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "Scenes/main.scene.json",
|
||||||
|
"sha256": "61b65baf79e52f07ac386a7f98acf985a1b09e88770cdde37ef461dc94b9dcd7"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "Scripts/Extensions/Beacon.hpp",
|
||||||
|
"sha256": "c60d56161eead16f541835053ad32ac3333383d83015709c060b7d45e0af5a97"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "Scripts/Gameplay.cpp",
|
||||||
|
"sha256": "5d419786a6861d7f14ed4eca54634694d8cd43be5dbbd60a2c6790db665c3a6a"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "Scripts/Gameplay.hpp",
|
||||||
|
"sha256": "4121587182ed4f74901b1c0757c53daadbf309af1d6bb5dd87fef142130ccbbe"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "project.faset.json",
|
||||||
|
"sha256": "43d273b1054ec9069369073f54cc1fc7efb3d6b699dcd342c552a2dc2ef852ef"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"package_file_count": 22,
|
||||||
|
"source_project_paths_unavailable": true,
|
||||||
|
"standalone_validation": "passed",
|
||||||
|
"standalone_render_frames": 120,
|
||||||
|
"capture": {
|
||||||
|
"width": 1280,
|
||||||
|
"height": 720,
|
||||||
|
"sampled_colors": 42,
|
||||||
|
"sha256": "914060401de93d08b33bf09a1e210859e491a0060c331e5e2044f517356f4980"
|
||||||
|
},
|
||||||
|
"final_profile": "collect-3d-profile-240.json",
|
||||||
|
"profile_recorded_at_utc": "2026-09-18T01:51:25.501500+00:00",
|
||||||
|
"gpu_allocated_bytes": {
|
||||||
|
"min": 15787008,
|
||||||
|
"max": 15787008
|
||||||
|
},
|
||||||
|
"draw_calls": [
|
||||||
|
31
|
||||||
|
],
|
||||||
|
"vertices": [
|
||||||
|
576
|
||||||
|
],
|
||||||
|
"texture_count": [
|
||||||
|
1
|
||||||
|
],
|
||||||
|
"summary_ms": {
|
||||||
|
"gpu": {
|
||||||
|
"max": 0.624064,
|
||||||
|
"min": 0.58432,
|
||||||
|
"p50": 0.58752,
|
||||||
|
"p95": 0.600448,
|
||||||
|
"samples": 240
|
||||||
|
},
|
||||||
|
"render_call": {
|
||||||
|
"max": 4.807059,
|
||||||
|
"min": 1.644403,
|
||||||
|
"p50": 1.884165,
|
||||||
|
"p95": 2.179593,
|
||||||
|
"samples": 240
|
||||||
|
},
|
||||||
|
"renderer_cpu": {
|
||||||
|
"max": 4.803463,
|
||||||
|
"min": 1.626759,
|
||||||
|
"p50": 1.880197,
|
||||||
|
"p95": 2.174082,
|
||||||
|
"samples": 240
|
||||||
|
},
|
||||||
|
"renderer_readback_cpu": {
|
||||||
|
"max": 2.729419,
|
||||||
|
"min": 0.398812,
|
||||||
|
"p50": 0.468945,
|
||||||
|
"p95": 0.608337,
|
||||||
|
"samples": 240
|
||||||
|
},
|
||||||
|
"simulation": {
|
||||||
|
"max": 0.441703,
|
||||||
|
"min": 0.185701,
|
||||||
|
"p50": 0.213223,
|
||||||
|
"p95": 0.300146,
|
||||||
|
"samples": 240
|
||||||
|
},
|
||||||
|
"snapshot": {
|
||||||
|
"max": 0.740007,
|
||||||
|
"min": 0.162066,
|
||||||
|
"p50": 0.181302,
|
||||||
|
"p95": 0.257285,
|
||||||
|
"samples": 240
|
||||||
|
},
|
||||||
|
"wall": {
|
||||||
|
"max": 5.902556,
|
||||||
|
"min": 2.040269,
|
||||||
|
"p50": 2.298847,
|
||||||
|
"p95": 2.732225,
|
||||||
|
"samples": 240
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"evidence_sha256": {
|
||||||
|
"collect-2d-profile-240.json": "0141cb2ad6d31394b542d575dce3409fef82ff2e77fe91eb4a7183811471e36f",
|
||||||
|
"collect-3d-profile-240.json": "bedf3a68df03c1af42bdb4d3b416d930c6e92fa6e99033c63ea9b17ae977c8c6",
|
||||||
|
"collect-2d-manifest.json": "83a0c8a58dae07376c0db58ca58b115d9ecedb245bcba88bdcd5429796ba5be9",
|
||||||
|
"collect-3d-manifest.json": "6374a6af5618a6c613b09978b2bf42300b1565808ded21bf9c2b82444027f252",
|
||||||
|
"collect-2d.png": "c380583f2fd8bc09c5b0e1c6e755c35c3d64b5385ed66177b04d1bbdb532b236",
|
||||||
|
"collect-3d.png": "98941a2004c3345da336294fa0e1b9e363922fc581c7a8612652ecbb0d764c07"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
{
|
||||||
|
"format": "faset.native-window-validation",
|
||||||
|
"version": 1,
|
||||||
|
"date": "2026-09-18",
|
||||||
|
"platform": "Linux, KDE Wayland session and isolated Xvfb",
|
||||||
|
"source_checkout": "Working tree around5ac4db4 plus checkpoint4 changes; not an exact clean-commit result",
|
||||||
|
"fixture": "tests/render_window_tests.cpp",
|
||||||
|
"cases": [
|
||||||
|
{
|
||||||
|
"surface": "XWayland in the active desktop",
|
||||||
|
"result": "passed",
|
||||||
|
"scope": [
|
||||||
|
"resize convergence",
|
||||||
|
"fresh readback while minimized",
|
||||||
|
"native minimize/restore events",
|
||||||
|
"live SDL display-scale getter",
|
||||||
|
"native SDL text input start/stop",
|
||||||
|
"drawable-pixel input-area conversion checked through SDL_GetTextInputArea"
|
||||||
|
],
|
||||||
|
"clipboard": "Skipped because the existing clipboard advertised non-text payload; no replacement attempted.",
|
||||||
|
"log": "driver=x11 stage=created\nstage=resized width=360 height=300 resize_events=2\nclipboard=skipped_to_preserve_non_text_payload\ntext_input_area=coordinate_conversion_passed\nstage=minimized fresh_frames=12 minimize_events=1 focus_lost_events=1\nstage=restored restored_events=1 activation_required=0 frames=56 validation_enabled=1 validation_errors=0\n"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"surface": "Private Xvfb server, SDL X11 driver",
|
||||||
|
"result": "clipboard and input-area checks passed; lifecycle skipped",
|
||||||
|
"scope": [
|
||||||
|
"Unicode clipboard roundtrip through Renderer/SDL",
|
||||||
|
"saved plain-text clipboard restored without logging contents",
|
||||||
|
"native SDL text-input activation and rectangle conversion"
|
||||||
|
],
|
||||||
|
"limit": "No window manager; minimize request unconfirmed, so this is not minimize/restore acceptance.",
|
||||||
|
"log": "driver=x11 stage=created\nstage=resized width=360 height=300 resize_events=2\nclipboard=unicode_roundtrip_passed\ntext_input_area=coordinate_conversion_passed\nSKIP: window system declined the minimize request\n"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"surface": "Native Wayland in the active desktop",
|
||||||
|
"result": "partial; restore acceptance unresolved",
|
||||||
|
"scope": [
|
||||||
|
"resize convergence",
|
||||||
|
"12 fresh readbacks while SDL reported minimized"
|
||||||
|
],
|
||||||
|
"limit": "Compositor did not confirm restore/activation. SDL Wayland may synthesize its minimized flag because xdg-shell has no minimized-state acknowledgement; physical minimization is not independently established."
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"limits": [
|
||||||
|
"No real OS IME composition session was performed.",
|
||||||
|
"Synthetic text events in other tests do not substitute for real IME acceptance.",
|
||||||
|
"Windows native clipboard/window behavior requires its own run.",
|
||||||
|
"No clipboard contents were retained in logs or artifacts."
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"format": "faset.offline-build",
|
||||||
|
"version": 1,
|
||||||
|
"revision": "d834cfad67cd81d8c4998b90c16791361ca8c0f8",
|
||||||
|
"recorded_at_utc": "2026-09-18T01:33:07.301477+00:00",
|
||||||
|
"seconds": 174.03467636999994,
|
||||||
|
"returncode": 0,
|
||||||
|
"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"
|
||||||
|
}
|
||||||
@@ -43,10 +43,13 @@ struct ImportRequest {
|
|||||||
Json settings = nullptr; // Null restores the sidecar recipe; an object replaces it.
|
Json settings = nullptr; // Null restores the sidecar recipe; an object replaces it.
|
||||||
// Explicit conflict resolution; false keeps the previous generation active.
|
// Explicit conflict resolution; false keeps the previous generation active.
|
||||||
bool allow_removed_outputs = false;
|
bool allow_removed_outputs = false;
|
||||||
|
// Optional review guards: reject a changed candidate or active generation.
|
||||||
|
// Empty keeps the ordinary import API; the Editor sets both on explicit acceptance.
|
||||||
|
std::string expected_generation{}, expected_active_generation{};
|
||||||
};
|
};
|
||||||
struct ImportResult {
|
struct ImportResult {
|
||||||
ImportStatus status = ImportStatus::failed;
|
ImportStatus status = ImportStatus::failed;
|
||||||
std::string asset_id, generation;
|
std::string asset_id, generation, previous_generation;
|
||||||
std::vector<std::string> diagnostics;
|
std::vector<std::string> diagnostics;
|
||||||
std::vector<std::string> removed_output_ids;
|
std::vector<std::string> removed_output_ids;
|
||||||
Json manifest;
|
Json manifest;
|
||||||
|
|||||||
@@ -5,6 +5,19 @@
|
|||||||
#include <string_view>
|
#include <string_view>
|
||||||
|
|
||||||
namespace faset {
|
namespace faset {
|
||||||
|
// Text/JSON/process arguments use UTF-8. Keep filesystem paths in native form
|
||||||
|
// internally; narrow path constructors/string() depend on the Windows code page.
|
||||||
|
std::filesystem::path path_from_utf8(std::string_view text);
|
||||||
|
std::string path_to_utf8(const std::filesystem::path& path);
|
||||||
|
std::string generic_path_to_utf8(const std::filesystem::path& path);
|
||||||
|
// Use only at file-I/O boundaries, never for serialized paths or identity comparisons.
|
||||||
|
// Windows: absolute, normalized extended-length path; other systems: unchanged.
|
||||||
|
std::filesystem::path native_io_path(const std::filesystem::path& path);
|
||||||
|
#ifdef _WIN32
|
||||||
|
// Thin wmain adapter: preserve the CRT's Unicode argument parsing and pass UTF-8
|
||||||
|
// to the shared application entry. No process/global code-page change is needed.
|
||||||
|
int run_utf8_main(int argc, wchar_t** argv, int (*entry)(int, char**)) noexcept;
|
||||||
|
#endif
|
||||||
std::string new_id();
|
std::string new_id();
|
||||||
std::string read_text(const std::filesystem::path& path);
|
std::string read_text(const std::filesystem::path& path);
|
||||||
Json read_json(const std::filesystem::path& path);
|
Json read_json(const std::filesystem::path& path);
|
||||||
|
|||||||
@@ -8,10 +8,11 @@
|
|||||||
|
|
||||||
namespace faset {
|
namespace faset {
|
||||||
struct ProcessOptions {
|
struct ProcessOptions {
|
||||||
// First element is the executable. Arguments are passed directly, never through a shell.
|
// UTF-8 text; the first element is the executable (use path_to_utf8 for paths).
|
||||||
|
// Arguments are passed directly, never through a shell.
|
||||||
std::vector<std::string> arguments;
|
std::vector<std::string> arguments;
|
||||||
std::filesystem::path working_directory;
|
std::filesystem::path working_directory;
|
||||||
std::map<std::string, std::string> environment;
|
std::map<std::string, std::string> environment; // UTF-8 names and values.
|
||||||
};
|
};
|
||||||
struct ProcessPoll {
|
struct ProcessPoll {
|
||||||
bool running{};
|
bool running{};
|
||||||
|
|||||||
@@ -32,20 +32,23 @@ struct Mesh {
|
|||||||
std::shared_ptr<const Mesh> cube_mesh();
|
std::shared_ptr<const Mesh> cube_mesh();
|
||||||
struct Texture;
|
struct Texture;
|
||||||
struct DrawItem {
|
struct DrawItem {
|
||||||
std::shared_ptr<const Mesh> mesh;
|
std::shared_ptr<const Mesh> mesh{};
|
||||||
Mat4 model{identity};
|
Mat4 model{identity};
|
||||||
Color color{1, 1, 1, 1};
|
Color color{1, 1, 1, 1};
|
||||||
float roughness{0.65f};
|
float roughness{0.65f};
|
||||||
float metallic{0.0f};
|
float metallic{0.0f};
|
||||||
bool cast_shadow{true};
|
bool cast_shadow{true};
|
||||||
std::shared_ptr<const Texture> texture;
|
std::shared_ptr<const Texture> texture{};
|
||||||
};
|
};
|
||||||
struct Sprite {
|
struct Sprite {
|
||||||
Vec3 position{};
|
Vec3 position{};
|
||||||
Vec2 size{1, 1};
|
Vec2 size{1, 1};
|
||||||
Color color{1, 1, 1, 1};
|
Color color{1, 1, 1, 1};
|
||||||
float rotation{};
|
float rotation{};
|
||||||
std::shared_ptr<const Texture> texture;
|
std::shared_ptr<const Texture> texture{};
|
||||||
|
// Higher layers draw later. Within a layer, projected depth is sorted back to front;
|
||||||
|
// equal-depth sprites retain their submission order.
|
||||||
|
int layer{};
|
||||||
};
|
};
|
||||||
struct Texture {
|
struct Texture {
|
||||||
std::uint32_t width{}, height{};
|
std::uint32_t width{}, height{};
|
||||||
@@ -56,7 +59,7 @@ struct Texture {
|
|||||||
struct Quad {
|
struct Quad {
|
||||||
float x{}, y{}, width{}, height{};
|
float x{}, y{}, width{}, height{};
|
||||||
Color color{1, 1, 1, 1};
|
Color color{1, 1, 1, 1};
|
||||||
std::shared_ptr<const Texture> texture;
|
std::shared_ptr<const Texture> texture{};
|
||||||
std::array<float, 4> uv_rect{0, 0, 1, 1};
|
std::array<float, 4> uv_rect{0, 0, 1, 1};
|
||||||
};
|
};
|
||||||
struct Text {
|
struct Text {
|
||||||
@@ -88,7 +91,7 @@ struct RendererConfig {
|
|||||||
bool headless{false};
|
bool headless{false};
|
||||||
bool validation{true};
|
bool validation{true};
|
||||||
// Optional isolated shader bundle, useful for editor preview and shader reload tests.
|
// Optional isolated shader bundle, useful for editor preview and shader reload tests.
|
||||||
std::filesystem::path shader_directory;
|
std::filesystem::path shader_directory{};
|
||||||
};
|
};
|
||||||
struct Event {
|
struct Event {
|
||||||
enum class Type {
|
enum class Type {
|
||||||
@@ -141,6 +144,8 @@ class Renderer {
|
|||||||
std::vector<std::uint8_t> pixels() const;
|
std::vector<std::uint8_t> pixels() const;
|
||||||
std::uint32_t width() const;
|
std::uint32_t width() const;
|
||||||
std::uint32_t height() const;
|
std::uint32_t height() const;
|
||||||
|
// Effective UI scale in drawable pixels; updates when the window changes display.
|
||||||
|
float display_scale() const;
|
||||||
bool should_close() const;
|
bool should_close() const;
|
||||||
const FrameStats& stats() const;
|
const FrameStats& stats() const;
|
||||||
void set_title(const std::string&);
|
void set_title(const std::string&);
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
#pragma once
|
||||||
|
#include <nlohmann/json_fwd.hpp>
|
||||||
|
#include <string_view>
|
||||||
|
|
||||||
|
namespace faset::runtime {
|
||||||
|
// Exact native TypeIds. A prefix alone does not make a custom type native.
|
||||||
|
bool is_builtin_component(std::string_view type) noexcept;
|
||||||
|
|
||||||
|
// Player boundary: require every custom TypeId and its exact positive integer
|
||||||
|
// version in the linked gameplay schema. Accepts its array or a {types:[...]}
|
||||||
|
// manifest. Native components always require version 1. A missing version means
|
||||||
|
// 1 for existing development scenes. Gameplay may not redeclare native TypeIds.
|
||||||
|
// Does not migrate or change source data.
|
||||||
|
// Structural/native field checks remain Runtime::load's responsibility; this
|
||||||
|
// function has no authoring dependency and runs no gameplay callbacks.
|
||||||
|
void validate_scene_schemas(const nlohmann::json& scene, const nlohmann::json& gameplay_schema);
|
||||||
|
} // namespace faset::runtime
|
||||||
@@ -162,6 +162,9 @@ class Context {
|
|||||||
// edit state.
|
// edit state.
|
||||||
void validate_layout(const Json&) const;
|
void validate_layout(const Json&) const;
|
||||||
void apply_layout(const Json&);
|
void apply_layout(const Json&);
|
||||||
|
// Layout metrics are logical units; rectangles/events/IME areas are drawable
|
||||||
|
// pixels. Scale also selects the glyph raster size (supported range .5–4).
|
||||||
|
// Changing it preserves text/focus and cancels active pointer drags.
|
||||||
void layout(float drawable_width, float drawable_height, float dpi_scale = 1);
|
void layout(float drawable_width, float drawable_height, float dpi_scale = 1);
|
||||||
bool handle(const render::Event&);
|
bool handle(const render::Event&);
|
||||||
void draw(render::Snapshot&);
|
void draw(render::Snapshot&);
|
||||||
|
|||||||
@@ -19,9 +19,9 @@ void valid_id(const std::string& id) {
|
|||||||
throw std::runtime_error("Invalid AssetId");
|
throw std::runtime_error("Invalid AssetId");
|
||||||
}
|
}
|
||||||
std::vector<std::byte> read_bytes(const fs::path& path) {
|
std::vector<std::byte> read_bytes(const fs::path& path) {
|
||||||
std::ifstream file(path, std::ios::binary | std::ios::ate);
|
std::ifstream file(faset::native_io_path(path), std::ios::binary | std::ios::ate);
|
||||||
if (!file)
|
if (!file)
|
||||||
throw std::runtime_error("Cannot read cooked file: " + path.string());
|
throw std::runtime_error("Cannot read cooked file: " + faset::path_to_utf8(path));
|
||||||
auto length = file.tellg();
|
auto length = file.tellg();
|
||||||
if (length < 0 || static_cast<std::uint64_t>(length) > 1024ull * 1024 * 1024)
|
if (length < 0 || static_cast<std::uint64_t>(length) > 1024ull * 1024 * 1024)
|
||||||
throw std::runtime_error("Cooked file exceeds 1 GiB limit");
|
throw std::runtime_error("Cooked file exceeds 1 GiB limit");
|
||||||
@@ -42,7 +42,7 @@ Json read_json(const fs::path& path) {
|
|||||||
fs::path cooked_path(const fs::path& directory, const std::string& name) {
|
fs::path cooked_path(const fs::path& directory, const std::string& name) {
|
||||||
if (name.empty())
|
if (name.empty())
|
||||||
throw std::runtime_error("Empty cooked path");
|
throw std::runtime_error("Empty cooked path");
|
||||||
return faset::project_path(directory, fs::path(name));
|
return faset::project_path(directory, faset::path_from_utf8(name));
|
||||||
}
|
}
|
||||||
void validate_generation(const fs::path& directory, const Json& manifest) {
|
void validate_generation(const fs::path& directory, const Json& manifest) {
|
||||||
if (manifest.at("schema_version") != 1)
|
if (manifest.at("schema_version") != 1)
|
||||||
@@ -142,7 +142,7 @@ CookedAsset AssetStore::load_asset(const std::string& id) const {
|
|||||||
const auto m = read_json(directory / "manifest.json");
|
const auto m = read_json(directory / "manifest.json");
|
||||||
validate_generation(directory, m);
|
validate_generation(directory, m);
|
||||||
if (m.at("asset_id").get<std::string>() != id ||
|
if (m.at("asset_id").get<std::string>() != id ||
|
||||||
m.at("generation").get<std::string>() != directory.filename().string())
|
m.at("generation").get<std::string>() != faset::path_to_utf8(directory.filename()))
|
||||||
throw std::runtime_error("Cooked asset identity does not match its generation");
|
throw std::runtime_error("Cooked asset identity does not match its generation");
|
||||||
CookedAsset asset;
|
CookedAsset asset;
|
||||||
asset.asset_id = m.at("asset_id");
|
asset.asset_id = m.at("asset_id");
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
#include <cgltf.h>
|
#include <cgltf.h>
|
||||||
#include <faset/assets/asset_pipeline.hpp>
|
#include <faset/assets/asset_pipeline.hpp>
|
||||||
#include <faset/core/hash.hpp>
|
#include <faset/core/hash.hpp>
|
||||||
|
#include <faset/core/io.hpp>
|
||||||
// Import validation owns a private decoder; Player's decoder remains a separate
|
// Import validation owns a private decoder; Player's decoder remains a separate
|
||||||
// binary boundary.
|
// binary boundary.
|
||||||
#define STB_IMAGE_IMPLEMENTATION
|
#define STB_IMAGE_IMPLEMENTATION
|
||||||
@@ -63,33 +64,33 @@ void valid_id(const std::string& id) {
|
|||||||
throw std::runtime_error("Invalid AssetId");
|
throw std::runtime_error("Invalid AssetId");
|
||||||
}
|
}
|
||||||
std::vector<std::byte> read_bytes(const fs::path& path) {
|
std::vector<std::byte> read_bytes(const fs::path& path) {
|
||||||
std::ifstream file(path, std::ios::binary | std::ios::ate);
|
std::ifstream file(faset::native_io_path(path), std::ios::binary | std::ios::ate);
|
||||||
if (!file)
|
if (!file)
|
||||||
throw std::runtime_error("Cannot read: " + path.string());
|
throw std::runtime_error("Cannot read: " + faset::path_to_utf8(path));
|
||||||
const auto length = file.tellg();
|
const auto length = file.tellg();
|
||||||
if (length < 0 || static_cast<std::uint64_t>(length) > 1024ull * 1024 * 1024)
|
if (length < 0 || static_cast<std::uint64_t>(length) > 1024ull * 1024 * 1024)
|
||||||
throw std::runtime_error("Input exceeds 1 GiB limit: " + path.string());
|
throw std::runtime_error("Input exceeds 1 GiB limit: " + faset::path_to_utf8(path));
|
||||||
std::vector<std::byte> data(static_cast<std::size_t>(length));
|
std::vector<std::byte> data(static_cast<std::size_t>(length));
|
||||||
file.seekg(0);
|
file.seekg(0);
|
||||||
if (!data.empty() &&
|
if (!data.empty() &&
|
||||||
!file.read(reinterpret_cast<char*>(data.data()), static_cast<std::streamsize>(data.size())))
|
!file.read(reinterpret_cast<char*>(data.data()), static_cast<std::streamsize>(data.size())))
|
||||||
throw std::runtime_error("Short read: " + path.string());
|
throw std::runtime_error("Short read: " + faset::path_to_utf8(path));
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
void write_bytes(const fs::path& path, const std::vector<std::byte>& data) {
|
void write_bytes(const fs::path& path, const std::vector<std::byte>& data) {
|
||||||
fs::create_directories(path.parent_path());
|
fs::create_directories(path.parent_path());
|
||||||
std::ofstream out(path, std::ios::binary | std::ios::trunc);
|
std::ofstream out(faset::native_io_path(path), std::ios::binary | std::ios::trunc);
|
||||||
if (!out || (!data.empty() && !out.write(reinterpret_cast<const char*>(data.data()),
|
if (!out || (!data.empty() && !out.write(reinterpret_cast<const char*>(data.data()),
|
||||||
static_cast<std::streamsize>(data.size()))))
|
static_cast<std::streamsize>(data.size()))))
|
||||||
throw std::runtime_error("Cannot write: " + path.string());
|
throw std::runtime_error("Cannot write: " + faset::path_to_utf8(path));
|
||||||
out.close();
|
out.close();
|
||||||
if (!out)
|
if (!out)
|
||||||
throw std::runtime_error("Cannot close: " + path.string());
|
throw std::runtime_error("Cannot close: " + faset::path_to_utf8(path));
|
||||||
}
|
}
|
||||||
Json read_json(const fs::path& path) {
|
Json read_json(const fs::path& path) {
|
||||||
std::ifstream in(path);
|
std::ifstream in(faset::native_io_path(path));
|
||||||
if (!in)
|
if (!in)
|
||||||
throw std::runtime_error("Cannot read JSON: " + path.string());
|
throw std::runtime_error("Cannot read JSON: " + faset::path_to_utf8(path));
|
||||||
return Json::parse(in);
|
return Json::parse(in);
|
||||||
}
|
}
|
||||||
void write_json(const fs::path& path, const Json& value) {
|
void write_json(const fs::path& path, const Json& value) {
|
||||||
@@ -105,9 +106,10 @@ void atomic_json(const fs::path& path, const Json& value) {
|
|||||||
try {
|
try {
|
||||||
write_json(temporary, value);
|
write_json(temporary, value);
|
||||||
#ifdef _WIN32
|
#ifdef _WIN32
|
||||||
if (!MoveFileExW(temporary.c_str(), path.c_str(),
|
if (!MoveFileExW(faset::native_io_path(temporary).c_str(),
|
||||||
|
faset::native_io_path(path).c_str(),
|
||||||
MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH))
|
MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH))
|
||||||
throw std::runtime_error("Atomic replace failed: " + path.string());
|
throw std::runtime_error("Atomic replace failed: " + faset::path_to_utf8(path));
|
||||||
#else
|
#else
|
||||||
fs::rename(temporary, path);
|
fs::rename(temporary, path);
|
||||||
#endif
|
#endif
|
||||||
@@ -177,7 +179,7 @@ std::vector<std::byte> decode_data_uri(const std::string& uri) {
|
|||||||
fs::path external_path(const fs::path& source, const std::string& uri) {
|
fs::path external_path(const fs::path& source, const std::string& uri) {
|
||||||
if (uri.find("://") != std::string::npos)
|
if (uri.find("://") != std::string::npos)
|
||||||
throw std::runtime_error("Network URI is not an import dependency: " + uri);
|
throw std::runtime_error("Network URI is not an import dependency: " + uri);
|
||||||
const fs::path relative = uri_decode(uri);
|
const fs::path relative = faset::path_from_utf8(uri_decode(uri));
|
||||||
if (relative.is_absolute())
|
if (relative.is_absolute())
|
||||||
throw std::runtime_error("glTF URI must be relative");
|
throw std::runtime_error("glTF URI must be relative");
|
||||||
return (source.parent_path() / relative).lexically_normal();
|
return (source.parent_path() / relative).lexically_normal();
|
||||||
@@ -306,13 +308,15 @@ void validate_generation(const fs::path& directory, const Json& manifest) {
|
|||||||
if (manifest.at("schema_version") != 1)
|
if (manifest.at("schema_version") != 1)
|
||||||
throw std::runtime_error("Unsupported asset manifest version");
|
throw std::runtime_error("Unsupported asset manifest version");
|
||||||
for (const auto& file : manifest.at("files")) {
|
for (const auto& file : manifest.at("files")) {
|
||||||
const fs::path relative = file.at("path").get<std::string>();
|
const fs::path relative = faset::path_from_utf8(file.at("path").get<std::string>());
|
||||||
if (relative.is_absolute() || relative.string().find("..") != std::string::npos)
|
if (relative.is_absolute() ||
|
||||||
|
faset::generic_path_to_utf8(relative).find("..") != std::string::npos)
|
||||||
throw std::runtime_error("Invalid cooked file path");
|
throw std::runtime_error("Invalid cooked file path");
|
||||||
auto bytes = read_bytes(directory / relative);
|
auto bytes = read_bytes(directory / relative);
|
||||||
if (bytes.size() != file.at("size").get<std::size_t>() ||
|
if (bytes.size() != file.at("size").get<std::size_t>() ||
|
||||||
hash_bytes(bytes) != file.at("sha256").get<std::string>())
|
hash_bytes(bytes) != file.at("sha256").get<std::string>())
|
||||||
throw std::runtime_error("Corrupt cooked file: " + relative.string());
|
throw std::runtime_error("Corrupt cooked file: " +
|
||||||
|
faset::generic_path_to_utf8(relative));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Json material_json(const Material& m) {
|
Json material_json(const Material& m) {
|
||||||
@@ -387,8 +391,9 @@ ImportResult AssetPipeline::import_asset(const ImportRequest& request, ImportJob
|
|||||||
valid_id(bundle_asset_id);
|
valid_id(bundle_asset_id);
|
||||||
bool found_payload = false;
|
bool found_payload = false;
|
||||||
for (const auto& file : bundle.at("files")) {
|
for (const auto& file : bundle.at("files")) {
|
||||||
const auto relative = fs::path(file.at("path").get<std::string>());
|
const auto relative = faset::path_from_utf8(file.at("path").get<std::string>());
|
||||||
if (relative.is_absolute() || relative.string().find("..") != std::string::npos)
|
if (relative.is_absolute() ||
|
||||||
|
faset::generic_path_to_utf8(relative).find("..") != std::string::npos)
|
||||||
throw std::runtime_error("Invalid bundle payload path");
|
throw std::runtime_error("Invalid bundle payload path");
|
||||||
const auto candidate = logical_source.parent_path() / relative;
|
const auto candidate = logical_source.parent_path() / relative;
|
||||||
const auto bytes = read_bytes(candidate);
|
const auto bytes = read_bytes(candidate);
|
||||||
@@ -407,7 +412,8 @@ ImportResult AssetPipeline::import_asset(const ImportRequest& request, ImportJob
|
|||||||
}
|
}
|
||||||
const auto source_bytes = source == logical_source ? logical_bytes : payload_snapshot;
|
const auto source_bytes = source == logical_source ? logical_bytes : payload_snapshot;
|
||||||
const auto source_hash = hash_bytes(source_bytes);
|
const auto source_hash = hash_bytes(source_bytes);
|
||||||
const auto sidecar = fs::path(logical_source.string() + ".faset-import.json");
|
auto sidecar = logical_source;
|
||||||
|
sidecar += ".faset-import.json";
|
||||||
Json metadata = fs::exists(sidecar) ? read_json(sidecar) : Json::object();
|
Json metadata = fs::exists(sidecar) ? read_json(sidecar) : Json::object();
|
||||||
if (!metadata.is_object() ||
|
if (!metadata.is_object() ||
|
||||||
(!metadata.empty() && metadata.value("schema_version", 0) != 1))
|
(!metadata.empty() && metadata.value("schema_version", 0) != 1))
|
||||||
@@ -431,14 +437,15 @@ ImportResult AssetPipeline::import_asset(const ImportRequest& request, ImportJob
|
|||||||
Json previous =
|
Json previous =
|
||||||
fs::exists(asset_root / "current.json") ? current_manifest(result.asset_id) : Json();
|
fs::exists(asset_root / "current.json") ? current_manifest(result.asset_id) : Json();
|
||||||
if (!previous.is_null()) {
|
if (!previous.is_null()) {
|
||||||
const auto previous_source = fs::path(previous.at("source").get<std::string>());
|
const auto previous_source =
|
||||||
|
faset::path_from_utf8(previous.at("source").get<std::string>());
|
||||||
if (previous_source != logical_source && fs::exists(previous_source))
|
if (previous_source != logical_source && fs::exists(previous_source))
|
||||||
throw std::runtime_error("Duplicate AssetId: previous source still exists");
|
throw std::runtime_error("Duplicate AssetId: previous source still exists");
|
||||||
}
|
}
|
||||||
Dependencies dependencies;
|
Dependencies dependencies;
|
||||||
CookedAsset asset;
|
CookedAsset asset;
|
||||||
asset.asset_id = result.asset_id;
|
asset.asset_id = result.asset_id;
|
||||||
auto extension = source.extension().string();
|
auto extension = faset::path_to_utf8(source.extension());
|
||||||
std::transform(extension.begin(), extension.end(), extension.begin(),
|
std::transform(extension.begin(), extension.end(), extension.begin(),
|
||||||
[](unsigned char c) { return static_cast<char>(std::tolower(c)); });
|
[](unsigned char c) { return static_cast<char>(std::tolower(c)); });
|
||||||
const bool standalone_image =
|
const bool standalone_image =
|
||||||
@@ -514,7 +521,7 @@ ImportResult AssetPipeline::import_asset(const ImportRequest& request, ImportJob
|
|||||||
data->buffers[i].data_free_method = cgltf_data_free_method_none;
|
data->buffers[i].data_free_method = cgltf_data_free_method_none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (cgltf_load_buffers(&options, data.get(), source.string().c_str()) !=
|
if (cgltf_load_buffers(&options, data.get(), faset::path_to_utf8(source).c_str()) !=
|
||||||
cgltf_result_success)
|
cgltf_result_success)
|
||||||
throw std::runtime_error("Cannot load glTF buffers");
|
throw std::runtime_error("Cannot load glTF buffers");
|
||||||
if (cgltf_validate(data.get()) != cgltf_result_success)
|
if (cgltf_validate(data.get()) != cgltf_result_success)
|
||||||
@@ -710,8 +717,8 @@ ImportResult AssetPipeline::import_asset(const ImportRequest& request, ImportJob
|
|||||||
Json manifest{{"schema_version", 1},
|
Json manifest{{"schema_version", 1},
|
||||||
{"asset_id", result.asset_id},
|
{"asset_id", result.asset_id},
|
||||||
{"generation", result.generation},
|
{"generation", result.generation},
|
||||||
{"source", logical_source.string()},
|
{"source", faset::path_to_utf8(logical_source)},
|
||||||
{"payload_source", source.string()},
|
{"payload_source", faset::path_to_utf8(source)},
|
||||||
{"source_sha256", source_hash},
|
{"source_sha256", source_hash},
|
||||||
{"importer", recipe_version},
|
{"importer", recipe_version},
|
||||||
{"settings", settings},
|
{"settings", settings},
|
||||||
@@ -772,6 +779,19 @@ ImportResult AssetPipeline::import_asset(const ImportRequest& request, ImportJob
|
|||||||
if (!published_ids.contains(old.get<std::string>()))
|
if (!published_ids.contains(old.get<std::string>()))
|
||||||
result.removed_output_ids.push_back(old.get<std::string>());
|
result.removed_output_ids.push_back(old.get<std::string>());
|
||||||
result.manifest = manifest;
|
result.manifest = manifest;
|
||||||
|
result.previous_generation =
|
||||||
|
previous.is_null() ? "" : previous.at("generation").get<std::string>();
|
||||||
|
if ((!request.expected_generation.empty() &&
|
||||||
|
request.expected_generation != result.generation) ||
|
||||||
|
(!request.expected_active_generation.empty() &&
|
||||||
|
request.expected_active_generation != result.previous_generation)) {
|
||||||
|
result.status = ImportStatus::conflict;
|
||||||
|
result.diagnostics.push_back(
|
||||||
|
"Import changed since review; reimport and review the current removed outputs. "
|
||||||
|
"Active generation preserved");
|
||||||
|
fs::remove_all(stage);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
if (!result.removed_output_ids.empty() && !request.allow_removed_outputs) {
|
if (!result.removed_output_ids.empty() && !request.allow_removed_outputs) {
|
||||||
result.status = ImportStatus::conflict;
|
result.status = ImportStatus::conflict;
|
||||||
result.diagnostics.push_back(
|
result.diagnostics.push_back(
|
||||||
@@ -808,7 +828,7 @@ ImportResult AssetPipeline::import_asset(const ImportRequest& request, ImportJob
|
|||||||
throw Cancelled{};
|
throw Cancelled{};
|
||||||
atomic_json(asset_root / "current.json", {{"schema_version", 1},
|
atomic_json(asset_root / "current.json", {{"schema_version", 1},
|
||||||
{"generation", result.generation},
|
{"generation", result.generation},
|
||||||
{"source", logical_source.string()}});
|
{"source", faset::path_to_utf8(logical_source)}});
|
||||||
result.status = ImportStatus::succeeded;
|
result.status = ImportStatus::succeeded;
|
||||||
job.report(1, "complete");
|
job.report(1, "complete");
|
||||||
} catch (const Cancelled&) {
|
} catch (const Cancelled&) {
|
||||||
@@ -826,15 +846,15 @@ ImportResult AssetPipeline::import_asset(const ImportRequest& request, ImportJob
|
|||||||
}
|
}
|
||||||
Json AssetPipeline::overrides(const std::string& id) const {
|
Json AssetPipeline::overrides(const std::string& id) const {
|
||||||
const auto source = current_manifest(id).at("source").get<std::string>();
|
const auto source = current_manifest(id).at("source").get<std::string>();
|
||||||
const auto path = fs::path(source + ".faset-overrides.json");
|
const auto path = faset::path_from_utf8(source + ".faset-overrides.json");
|
||||||
return fs::exists(path) ? read_json(path) : Json::object();
|
return fs::exists(path) ? read_json(path) : Json::object();
|
||||||
}
|
}
|
||||||
void AssetPipeline::set_overrides(const std::string& id, const Json& values) {
|
void AssetPipeline::set_overrides(const std::string& id, const Json& values) {
|
||||||
if (!values.is_object())
|
if (!values.is_object())
|
||||||
throw std::runtime_error("Overrides must be an object keyed by stable output IDs");
|
throw std::runtime_error("Overrides must be an object keyed by stable output IDs");
|
||||||
std::lock_guard lock(writer_mutex);
|
std::lock_guard lock(writer_mutex);
|
||||||
atomic_json(
|
atomic_json(faset::path_from_utf8(current_manifest(id).at("source").get<std::string>() +
|
||||||
fs::path(current_manifest(id).at("source").get<std::string>() + ".faset-overrides.json"),
|
".faset-overrides.json"),
|
||||||
values);
|
values);
|
||||||
}
|
}
|
||||||
} // namespace faset::assets
|
} // namespace faset::assets
|
||||||
|
|||||||
@@ -233,7 +233,7 @@ Json AuthoringService::summary(const State& value, bool include_data) const {
|
|||||||
{"name", value.data.at("name")},
|
{"name", value.data.at("name")},
|
||||||
{"revision", value.revision},
|
{"revision", value.revision},
|
||||||
{"dirty", sha256(value.data.dump()) != value.saved_hash},
|
{"dirty", sha256(value.data.dump()) != value.saved_hash},
|
||||||
{"path", value.path.generic_string()},
|
{"path", generic_path_to_utf8(value.path)},
|
||||||
{"can_undo", !value.undo.empty()},
|
{"can_undo", !value.undo.empty()},
|
||||||
{"can_redo", !value.redo.empty()}};
|
{"can_redo", !value.redo.empty()}};
|
||||||
if (include_data)
|
if (include_data)
|
||||||
@@ -245,7 +245,7 @@ void AuthoringService::journal(const State& value) const {
|
|||||||
(value.data.at("id").get<std::string>() + ".json")),
|
(value.data.at("id").get<std::string>() + ".json")),
|
||||||
{{"format", "faset.recovery"},
|
{{"format", "faset.recovery"},
|
||||||
{"version", 1},
|
{"version", 1},
|
||||||
{"path", value.path.generic_string()},
|
{"path", generic_path_to_utf8(value.path)},
|
||||||
{"revision", value.revision},
|
{"revision", value.revision},
|
||||||
{"saved_hash", value.saved_hash},
|
{"saved_hash", value.saved_hash},
|
||||||
{"disk_hash", value.disk_hash},
|
{"disk_hash", value.disk_hash},
|
||||||
@@ -468,7 +468,7 @@ void AuthoringService::apply(Json& scene, const Json& command) {
|
|||||||
}
|
}
|
||||||
if (op == "template.source_set") {
|
if (op == "template.source_set") {
|
||||||
const auto source = command.at("source").get<std::string>();
|
const auto source = command.at("source").get<std::string>();
|
||||||
project_path(root_, source);
|
project_path(root_, path_from_utf8(source));
|
||||||
(*found)["source"] = source;
|
(*found)["source"] = source;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -593,7 +593,7 @@ Json AuthoringService::recover(const std::string& id,
|
|||||||
validate_scene(candidate.data, schemas_);
|
validate_scene(candidate.data, schemas_);
|
||||||
require(candidate.data.at("id") == id, "recovery.id",
|
require(candidate.data.at("id") == id, "recovery.id",
|
||||||
"Recovery ID does not match its document");
|
"Recovery ID does not match its document");
|
||||||
candidate.path = record.at("path").get<std::string>();
|
candidate.path = path_from_utf8(record.at("path").get<std::string>());
|
||||||
candidate.saved_hash = record.value("saved_hash", std::string());
|
candidate.saved_hash = record.value("saved_hash", std::string());
|
||||||
candidate.disk_hash = record.value("disk_hash", std::string());
|
candidate.disk_hash = record.value("disk_hash", std::string());
|
||||||
candidate.revision = record.value("revision", std::uint64_t(0));
|
candidate.revision = record.value("revision", std::uint64_t(0));
|
||||||
@@ -642,7 +642,7 @@ Json AuthoringService::recovery_documents() const {
|
|||||||
value.value("saved_hash", std::string())}});
|
value.value("saved_hash", std::string())}});
|
||||||
} catch (const std::exception&) {
|
} catch (const std::exception&) {
|
||||||
result.push_back({{"error", "Invalid recovery record"},
|
result.push_back({{"error", "Invalid recovery record"},
|
||||||
{"file", entry.path().filename().string()}});
|
{"file", path_to_utf8(entry.path().filename())}});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
|
|||||||
+4
-3
@@ -3,6 +3,7 @@
|
|||||||
#include <cstdint>
|
#include <cstdint>
|
||||||
#include <faset/core/error.hpp>
|
#include <faset/core/error.hpp>
|
||||||
#include <faset/core/hash.hpp>
|
#include <faset/core/hash.hpp>
|
||||||
|
#include <faset/core/io.hpp>
|
||||||
#include <fstream>
|
#include <fstream>
|
||||||
#include <vector>
|
#include <vector>
|
||||||
|
|
||||||
@@ -101,8 +102,8 @@ std::string sha256(std::span<const std::byte> bytes) {
|
|||||||
return digest.finish();
|
return digest.finish();
|
||||||
}
|
}
|
||||||
std::string sha256_file(const std::filesystem::path& path) {
|
std::string sha256_file(const std::filesystem::path& path) {
|
||||||
std::ifstream stream(path, std::ios::binary);
|
std::ifstream stream(native_io_path(path), std::ios::binary);
|
||||||
require(bool(stream), "io.open", "Cannot open file for hashing: " + path.string());
|
require(bool(stream), "io.open", "Cannot open file for hashing: " + path_to_utf8(path));
|
||||||
Digest digest;
|
Digest digest;
|
||||||
std::array<char, 65536> buffer{};
|
std::array<char, 65536> buffer{};
|
||||||
while (stream) {
|
while (stream) {
|
||||||
@@ -110,7 +111,7 @@ std::string sha256_file(const std::filesystem::path& path) {
|
|||||||
digest.update(
|
digest.update(
|
||||||
std::as_bytes(std::span(buffer.data(), static_cast<std::size_t>(stream.gcount()))));
|
std::as_bytes(std::span(buffer.data(), static_cast<std::size_t>(stream.gcount()))));
|
||||||
}
|
}
|
||||||
require(stream.eof(), "io.read", "Cannot read file for hashing: " + path.string());
|
require(stream.eof(), "io.read", "Cannot read file for hashing: " + path_to_utf8(path));
|
||||||
return digest.finish();
|
return digest.finish();
|
||||||
}
|
}
|
||||||
} // namespace faset
|
} // namespace faset
|
||||||
|
|||||||
+95
-11
@@ -2,8 +2,10 @@
|
|||||||
#include <faset/core/error.hpp>
|
#include <faset/core/error.hpp>
|
||||||
#include <faset/core/io.hpp>
|
#include <faset/core/io.hpp>
|
||||||
#include <fstream>
|
#include <fstream>
|
||||||
|
#include <iostream>
|
||||||
#include <mutex>
|
#include <mutex>
|
||||||
#include <random>
|
#include <random>
|
||||||
|
#include <vector>
|
||||||
#ifdef _WIN32
|
#ifdef _WIN32
|
||||||
#define NOMINMAX
|
#define NOMINMAX
|
||||||
#include <windows.h>
|
#include <windows.h>
|
||||||
@@ -13,6 +15,86 @@
|
|||||||
#endif
|
#endif
|
||||||
|
|
||||||
namespace faset {
|
namespace faset {
|
||||||
|
namespace {
|
||||||
|
void validate_utf8_path(std::string_view text) {
|
||||||
|
for (std::size_t i = 0; i < text.size();) {
|
||||||
|
const auto first = static_cast<unsigned char>(text[i++]);
|
||||||
|
require(first != 0, "path.encoding", "NUL in filesystem path");
|
||||||
|
if (first < 0x80)
|
||||||
|
continue;
|
||||||
|
unsigned remaining = first >= 0xc2 && first <= 0xdf ? 1
|
||||||
|
: first >= 0xe0 && first <= 0xef ? 2
|
||||||
|
: first >= 0xf0 && first <= 0xf4 ? 3
|
||||||
|
: 0;
|
||||||
|
require(remaining != 0 && i + remaining <= text.size(), "path.encoding",
|
||||||
|
"Invalid UTF-8 path");
|
||||||
|
const unsigned minimum = remaining == 1 ? 0x80 : remaining == 2 ? 0x800 : 0x10000;
|
||||||
|
unsigned code = first & ((1u << (6 - remaining)) - 1u);
|
||||||
|
while (remaining--) {
|
||||||
|
const auto next = static_cast<unsigned char>(text[i++]);
|
||||||
|
require((next & 0xc0) == 0x80, "path.encoding", "Invalid UTF-8 path");
|
||||||
|
code = (code << 6) | (next & 0x3f);
|
||||||
|
}
|
||||||
|
require(code >= minimum && code <= 0x10ffff && !(code >= 0xd800 && code <= 0xdfff),
|
||||||
|
"path.encoding", "Invalid UTF-8 path");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
std::string utf8_bytes(const std::u8string& value) {
|
||||||
|
return {reinterpret_cast<const char*>(value.data()), value.size()};
|
||||||
|
}
|
||||||
|
} // namespace
|
||||||
|
std::filesystem::path path_from_utf8(std::string_view text) {
|
||||||
|
validate_utf8_path(text);
|
||||||
|
return std::filesystem::path(std::u8string(text.begin(), text.end()));
|
||||||
|
}
|
||||||
|
std::string path_to_utf8(const std::filesystem::path& path) {
|
||||||
|
return utf8_bytes(path.u8string());
|
||||||
|
}
|
||||||
|
std::string generic_path_to_utf8(const std::filesystem::path& path) {
|
||||||
|
return utf8_bytes(path.generic_u8string());
|
||||||
|
}
|
||||||
|
std::filesystem::path native_io_path(const std::filesystem::path& path) {
|
||||||
|
#ifdef _WIN32
|
||||||
|
auto normalized = std::filesystem::absolute(path).lexically_normal();
|
||||||
|
normalized.make_preferred();
|
||||||
|
const auto& native = normalized.native();
|
||||||
|
if (native.starts_with(LR"(\\?\)") || native.starts_with(LR"(\\.\)"))
|
||||||
|
return normalized;
|
||||||
|
if (native.starts_with(LR"(\\)"))
|
||||||
|
return std::filesystem::path(std::wstring(LR"(\\?\UNC\)") + native.substr(2));
|
||||||
|
return std::filesystem::path(std::wstring(LR"(\\?\)") + native);
|
||||||
|
#else
|
||||||
|
return path;
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
#ifdef _WIN32
|
||||||
|
int run_utf8_main(int argc, wchar_t** argv, int (*entry)(int, char**)) noexcept {
|
||||||
|
try {
|
||||||
|
std::vector<std::string> arguments;
|
||||||
|
arguments.reserve(static_cast<std::size_t>(argc));
|
||||||
|
for (int i = 0; i < argc; ++i) {
|
||||||
|
const auto length = WideCharToMultiByte(CP_UTF8, WC_ERR_INVALID_CHARS, argv[i], -1,
|
||||||
|
nullptr, 0, nullptr, nullptr);
|
||||||
|
require(length > 0, "cli.encoding", "Invalid Unicode command-line argument");
|
||||||
|
std::string value(static_cast<std::size_t>(length), '\0');
|
||||||
|
require(WideCharToMultiByte(CP_UTF8, WC_ERR_INVALID_CHARS, argv[i], -1, value.data(),
|
||||||
|
length, nullptr, nullptr) == length,
|
||||||
|
"cli.encoding", "Cannot convert command-line argument to UTF-8");
|
||||||
|
value.pop_back();
|
||||||
|
arguments.push_back(std::move(value));
|
||||||
|
}
|
||||||
|
std::vector<char*> pointers;
|
||||||
|
pointers.reserve(arguments.size() + 1);
|
||||||
|
for (auto& value : arguments)
|
||||||
|
pointers.push_back(value.data());
|
||||||
|
pointers.push_back(nullptr);
|
||||||
|
return entry(argc, pointers.data());
|
||||||
|
} catch (const std::exception& error) {
|
||||||
|
std::cerr << "Command line failed: " << error.what() << '\n';
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
std::string new_id() {
|
std::string new_id() {
|
||||||
static std::mutex mutex;
|
static std::mutex mutex;
|
||||||
static std::random_device random;
|
static std::random_device random;
|
||||||
@@ -36,27 +118,29 @@ std::string new_id() {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
std::string read_text(const std::filesystem::path& path) {
|
std::string read_text(const std::filesystem::path& path) {
|
||||||
std::ifstream stream(path, std::ios::binary);
|
std::ifstream stream(native_io_path(path), std::ios::binary);
|
||||||
require(bool(stream), "io.open", "Cannot open file: " + path.string());
|
require(bool(stream), "io.open", "Cannot open file: " + path_to_utf8(path));
|
||||||
std::string value((std::istreambuf_iterator<char>(stream)), {});
|
std::string value((std::istreambuf_iterator<char>(stream)), {});
|
||||||
require(!stream.bad(), "io.read", "Cannot read file: " + path.string());
|
require(!stream.bad(), "io.read", "Cannot read file: " + path_to_utf8(path));
|
||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
Json read_json(const std::filesystem::path& path) {
|
Json read_json(const std::filesystem::path& path) {
|
||||||
try {
|
try {
|
||||||
return Json::parse(read_text(path));
|
return Json::parse(read_text(path));
|
||||||
} catch (const Json::exception& error) {
|
} catch (const Json::exception& error) {
|
||||||
throw Error("format.json", "Invalid JSON in " + path.string(), {{"reason", error.what()}});
|
throw Error("format.json", "Invalid JSON in " + path_to_utf8(path),
|
||||||
|
{{"reason", error.what()}});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
void atomic_write(const std::filesystem::path& path, std::string_view bytes) {
|
void atomic_write(const std::filesystem::path& path, std::string_view bytes) {
|
||||||
const auto parent = path.has_parent_path() ? path.parent_path() : std::filesystem::path(".");
|
const auto parent = path.has_parent_path() ? path.parent_path() : std::filesystem::path(".");
|
||||||
std::filesystem::create_directories(parent);
|
std::filesystem::create_directories(native_io_path(parent));
|
||||||
const auto temporary = parent / (path.filename().string() + ".tmp-" + new_id());
|
auto temporary = path;
|
||||||
|
temporary += ".tmp-" + new_id(); // Append ASCII to the native path without a narrow round trip.
|
||||||
try {
|
try {
|
||||||
#ifdef _WIN32
|
#ifdef _WIN32
|
||||||
HANDLE file = CreateFileW(temporary.c_str(), GENERIC_WRITE, 0, nullptr, CREATE_NEW,
|
HANDLE file = CreateFileW(native_io_path(temporary).c_str(), GENERIC_WRITE, 0, nullptr,
|
||||||
FILE_ATTRIBUTE_NORMAL, nullptr);
|
CREATE_NEW, FILE_ATTRIBUTE_NORMAL, nullptr);
|
||||||
require(file != INVALID_HANDLE_VALUE, "io.create", "Cannot create temporary file");
|
require(file != INVALID_HANDLE_VALUE, "io.create", "Cannot create temporary file");
|
||||||
bool ok = true;
|
bool ok = true;
|
||||||
std::size_t offset = 0;
|
std::size_t offset = 0;
|
||||||
@@ -73,9 +157,9 @@ void atomic_write(const std::filesystem::path& path, std::string_view bytes) {
|
|||||||
ok = FlushFileBuffers(file) && ok;
|
ok = FlushFileBuffers(file) && ok;
|
||||||
CloseHandle(file);
|
CloseHandle(file);
|
||||||
require(ok, "io.write", "Cannot flush temporary file");
|
require(ok, "io.write", "Cannot flush temporary file");
|
||||||
require(MoveFileExW(temporary.c_str(), path.c_str(),
|
require(MoveFileExW(native_io_path(temporary).c_str(), native_io_path(path).c_str(),
|
||||||
MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH) != 0,
|
MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH) != 0,
|
||||||
"io.replace", "Cannot publish file: " + path.string());
|
"io.replace", "Cannot publish file: " + path_to_utf8(path));
|
||||||
#else
|
#else
|
||||||
const int fd = ::open(temporary.c_str(), O_WRONLY | O_CREAT | O_EXCL, 0644);
|
const int fd = ::open(temporary.c_str(), O_WRONLY | O_CREAT | O_EXCL, 0644);
|
||||||
require(fd >= 0, "io.create", "Cannot create temporary file");
|
require(fd >= 0, "io.create", "Cannot create temporary file");
|
||||||
@@ -104,7 +188,7 @@ void atomic_write(const std::filesystem::path& path, std::string_view bytes) {
|
|||||||
#endif
|
#endif
|
||||||
} catch (...) {
|
} catch (...) {
|
||||||
std::error_code ignored;
|
std::error_code ignored;
|
||||||
std::filesystem::remove(temporary, ignored);
|
std::filesystem::remove(native_io_path(temporary), ignored);
|
||||||
throw;
|
throw;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+39
-25
@@ -131,6 +131,41 @@ struct Process::Impl {
|
|||||||
::close(output);
|
::close(output);
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
#ifndef _WIN32
|
||||||
|
bool collect_exit() {
|
||||||
|
// WNOWAIT keeps the exited leader's PID reserved until its owned group has
|
||||||
|
// been stopped. Never signal a PGID after reaping the leader: it may be reused.
|
||||||
|
siginfo_t information{};
|
||||||
|
int result;
|
||||||
|
do {
|
||||||
|
result =
|
||||||
|
::waitid(P_PID, static_cast<id_t>(pid), &information, WEXITED | WNOHANG | WNOWAIT);
|
||||||
|
} while (result < 0 && errno == EINTR);
|
||||||
|
if (result < 0) {
|
||||||
|
if (errno == ECHILD) {
|
||||||
|
running = false;
|
||||||
|
pid = -1;
|
||||||
|
}
|
||||||
|
throw std::runtime_error("Cannot collect child process");
|
||||||
|
}
|
||||||
|
if (information.si_pid == 0)
|
||||||
|
return false;
|
||||||
|
::kill(-pid, SIGKILL);
|
||||||
|
int status{};
|
||||||
|
pid_t reaped;
|
||||||
|
do {
|
||||||
|
reaped = ::waitpid(pid, &status, 0);
|
||||||
|
} while (reaped < 0 && errno == EINTR);
|
||||||
|
running = false;
|
||||||
|
pid = -1;
|
||||||
|
if (reaped < 0)
|
||||||
|
throw std::runtime_error("Cannot reap child process");
|
||||||
|
exit_code = WIFEXITED(status) ? WEXITSTATUS(status)
|
||||||
|
: WIFSIGNALED(status) ? 128 + WTERMSIG(status)
|
||||||
|
: 1;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
#endif
|
||||||
ProcessPoll poll() {
|
ProcessPoll poll() {
|
||||||
std::string text;
|
std::string text;
|
||||||
char buffer[8192];
|
char buffer[8192];
|
||||||
@@ -171,20 +206,8 @@ struct Process::Impl {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (running) {
|
if (running)
|
||||||
int status{};
|
collect_exit();
|
||||||
pid_t result;
|
|
||||||
do {
|
|
||||||
result = ::waitpid(pid, &status, WNOHANG);
|
|
||||||
} while (result < 0 && errno == EINTR);
|
|
||||||
if (result == pid) {
|
|
||||||
running = false;
|
|
||||||
exit_code = WIFEXITED(status) ? WEXITSTATUS(status)
|
|
||||||
: WIFSIGNALED(status) ? 128 + WTERMSIG(status)
|
|
||||||
: 1;
|
|
||||||
} else if (result < 0)
|
|
||||||
throw std::runtime_error("Cannot collect child process");
|
|
||||||
}
|
|
||||||
#endif
|
#endif
|
||||||
if (!running) {
|
if (!running) {
|
||||||
#ifdef _WIN32
|
#ifdef _WIN32
|
||||||
@@ -234,18 +257,8 @@ struct Process::Impl {
|
|||||||
::kill(-pid, SIGTERM);
|
::kill(-pid, SIGTERM);
|
||||||
const auto deadline = std::chrono::steady_clock::now() + std::chrono::milliseconds(200);
|
const auto deadline = std::chrono::steady_clock::now() + std::chrono::milliseconds(200);
|
||||||
while (std::chrono::steady_clock::now() < deadline) {
|
while (std::chrono::steady_clock::now() < deadline) {
|
||||||
int status{};
|
if (collect_exit())
|
||||||
auto result = ::waitpid(pid, &status, WNOHANG);
|
|
||||||
if (result == pid) {
|
|
||||||
running = false;
|
|
||||||
exit_code = WIFEXITED(status) ? WEXITSTATUS(status) : 128 + WTERMSIG(status);
|
|
||||||
::kill(-pid, SIGKILL);
|
|
||||||
return;
|
return;
|
||||||
}
|
|
||||||
if (result < 0 && errno != EINTR) {
|
|
||||||
running = false;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
std::this_thread::sleep_for(std::chrono::milliseconds(10));
|
std::this_thread::sleep_for(std::chrono::milliseconds(10));
|
||||||
}
|
}
|
||||||
::kill(-pid, SIGKILL);
|
::kill(-pid, SIGKILL);
|
||||||
@@ -253,6 +266,7 @@ struct Process::Impl {
|
|||||||
while (::waitpid(pid, &status, 0) < 0 && errno == EINTR) {
|
while (::waitpid(pid, &status, 0) < 0 && errno == EINTR) {
|
||||||
}
|
}
|
||||||
running = false;
|
running = false;
|
||||||
|
pid = -1;
|
||||||
exit_code = 130;
|
exit_code = 130;
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -74,7 +74,7 @@ std::set<std::string> asset_references(const Json& scene) {
|
|||||||
void copy_required_file(const fs::path& source, const fs::path& target) {
|
void copy_required_file(const fs::path& source, const fs::path& target) {
|
||||||
if (!fs::is_regular_file(source) || fs::is_symlink(source))
|
if (!fs::is_regular_file(source) || fs::is_symlink(source))
|
||||||
throw std::runtime_error("Required package file missing or not a regular file: " +
|
throw std::runtime_error("Required package file missing or not a regular file: " +
|
||||||
source.string());
|
path_to_utf8(source));
|
||||||
fs::create_directories(target.parent_path());
|
fs::create_directories(target.parent_path());
|
||||||
fs::copy_file(source, target, fs::copy_options::overwrite_existing);
|
fs::copy_file(source, target, fs::copy_options::overwrite_existing);
|
||||||
}
|
}
|
||||||
@@ -248,9 +248,9 @@ struct BuildService::Impl {
|
|||||||
fs::create_directories(native_directory);
|
fs::create_directories(native_directory);
|
||||||
std::vector<std::string> arguments = {config.cmake,
|
std::vector<std::string> arguments = {config.cmake,
|
||||||
"-S",
|
"-S",
|
||||||
config.engine_root.string(),
|
path_to_utf8(config.engine_root),
|
||||||
"-B",
|
"-B",
|
||||||
native_directory.string(),
|
path_to_utf8(native_directory),
|
||||||
"-G",
|
"-G",
|
||||||
config.generator,
|
config.generator,
|
||||||
"-DCMAKE_BUILD_TYPE=" + configuration,
|
"-DCMAKE_BUILD_TYPE=" + configuration,
|
||||||
@@ -260,7 +260,7 @@ struct BuildService::Impl {
|
|||||||
"-DFASET_BUILD_RUNTIME=ON",
|
"-DFASET_BUILD_RUNTIME=ON",
|
||||||
"-DFASET_BUILD_ASSETS=ON",
|
"-DFASET_BUILD_ASSETS=ON",
|
||||||
"-DFASET_GAMEPLAY_SOURCE_DIR=" +
|
"-DFASET_GAMEPLAY_SOURCE_DIR=" +
|
||||||
(config.project_root / "Scripts").string()};
|
path_to_utf8(config.project_root / "Scripts")};
|
||||||
bool compiler_overridden = false;
|
bool compiler_overridden = false;
|
||||||
for (const auto& arg : config.configure_arguments)
|
for (const auto& arg : config.configure_arguments)
|
||||||
if (arg.starts_with("-DCMAKE_CXX_COMPILER="))
|
if (arg.starts_with("-DCMAKE_CXX_COMPILER="))
|
||||||
@@ -268,11 +268,11 @@ struct BuildService::Impl {
|
|||||||
if (!compiler_overridden) {
|
if (!compiler_overridden) {
|
||||||
#ifdef _WIN32
|
#ifdef _WIN32
|
||||||
auto compiler = find_executable("clang-cl");
|
auto compiler = find_executable("clang-cl");
|
||||||
arguments.push_back("-DCMAKE_C_COMPILER=" + compiler.string());
|
arguments.push_back("-DCMAKE_C_COMPILER=" + path_to_utf8(compiler));
|
||||||
arguments.push_back("-DCMAKE_CXX_COMPILER=" + compiler.string());
|
arguments.push_back("-DCMAKE_CXX_COMPILER=" + path_to_utf8(compiler));
|
||||||
#else
|
#else
|
||||||
arguments.push_back("-DCMAKE_C_COMPILER=" + find_executable("clang").string());
|
arguments.push_back("-DCMAKE_C_COMPILER=" + path_to_utf8(find_executable("clang")));
|
||||||
arguments.push_back("-DCMAKE_CXX_COMPILER=" + find_executable("clang++").string());
|
arguments.push_back("-DCMAKE_CXX_COMPILER=" + path_to_utf8(find_executable("clang++")));
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
arguments.insert(arguments.end(), config.configure_arguments.begin(),
|
arguments.insert(arguments.end(), config.configure_arguments.begin(),
|
||||||
@@ -281,7 +281,7 @@ struct BuildService::Impl {
|
|||||||
run(job, std::move(arguments), config.project_root);
|
run(job, std::move(arguments), config.project_root);
|
||||||
checkpoint(job, "Compiling and linking Player", .25);
|
checkpoint(job, "Compiling and linking Player", .25);
|
||||||
run(job,
|
run(job,
|
||||||
{config.cmake, "--build", native_directory.string(), "--config", configuration,
|
{config.cmake, "--build", path_to_utf8(native_directory), "--config", configuration,
|
||||||
"--parallel", "4", "--target", "faset_player", "faset_schema_exporter"},
|
"--parallel", "4", "--target", "faset_player", "faset_schema_exporter"},
|
||||||
config.project_root);
|
config.project_root);
|
||||||
checkpoint(job, "Exporting gameplay schema", .58);
|
checkpoint(job, "Exporting gameplay schema", .58);
|
||||||
@@ -292,7 +292,8 @@ struct BuildService::Impl {
|
|||||||
fs::create_directories(staging);
|
fs::create_directories(staging);
|
||||||
try {
|
try {
|
||||||
const auto schema_file = staging / "schema.json";
|
const auto schema_file = staging / "schema.json";
|
||||||
run(job, {exporter.string(), "--output", schema_file.string()}, config.project_root);
|
run(job, {path_to_utf8(exporter), "--output", path_to_utf8(schema_file)},
|
||||||
|
config.project_root);
|
||||||
auto schema = read_json(schema_file);
|
auto schema = read_json(schema_file);
|
||||||
if (schema.value("format", "") != "faset.schema" || schema.value("version", 0) != 1 ||
|
if (schema.value("format", "") != "faset.schema" || schema.value("version", 0) != 1 ||
|
||||||
!schema.contains("types") || !schema.at("types").is_array())
|
!schema.contains("types") || !schema.at("types").is_array())
|
||||||
@@ -324,11 +325,11 @@ struct BuildService::Impl {
|
|||||||
atomic_write_json(config.cache_root / "last_build.json",
|
atomic_write_json(config.cache_root / "last_build.json",
|
||||||
{{"generation", job.status.id}, {"fingerprint", fingerprint}});
|
{{"generation", job.status.id}, {"fingerprint", fingerprint}});
|
||||||
return {{"generation", job.status.id},
|
return {{"generation", job.status.id},
|
||||||
{"directory", generation.string()},
|
{"directory", path_to_utf8(generation)},
|
||||||
{"build_directory", native_directory.string()},
|
{"build_directory", path_to_utf8(native_directory)},
|
||||||
{"configuration", configuration},
|
{"configuration", configuration},
|
||||||
{"player", (generation / ("faset_player" + executable_suffix())).string()},
|
{"player", path_to_utf8(generation / ("faset_player" + executable_suffix()))},
|
||||||
{"schema", (generation / "schema.json").string()},
|
{"schema", path_to_utf8(generation / "schema.json")},
|
||||||
{"fingerprint", fingerprint}};
|
{"fingerprint", fingerprint}};
|
||||||
} catch (...) {
|
} catch (...) {
|
||||||
std::error_code error;
|
std::error_code error;
|
||||||
@@ -346,7 +347,7 @@ struct BuildService::Impl {
|
|||||||
{executable.parent_path(), native_directory, native_directory / configuration})
|
{executable.parent_path(), native_directory, native_directory / configuration})
|
||||||
if (fs::is_directory(root))
|
if (fs::is_directory(root))
|
||||||
for (const auto& entry : fs::directory_iterator(root)) {
|
for (const auto& entry : fs::directory_iterator(root)) {
|
||||||
auto extension = entry.path().extension().string();
|
auto extension = path_to_utf8(entry.path().extension());
|
||||||
for (auto& c : extension)
|
for (auto& c : extension)
|
||||||
c = static_cast<char>(std::tolower(static_cast<unsigned char>(c)));
|
c = static_cast<char>(std::tolower(static_cast<unsigned char>(c)));
|
||||||
if (entry.is_regular_file() && extension == ".dll")
|
if (entry.is_regular_file() && extension == ".dll")
|
||||||
@@ -356,7 +357,8 @@ struct BuildService::Impl {
|
|||||||
(void)job;
|
(void)job;
|
||||||
#else
|
#else
|
||||||
const auto output =
|
const auto output =
|
||||||
run(job, {find_executable("ldd").string(), executable.string()}, config.project_root);
|
run(job, {path_to_utf8(find_executable("ldd")), path_to_utf8(executable)},
|
||||||
|
config.project_root);
|
||||||
if (output.find("not found") != std::string::npos)
|
if (output.find("not found") != std::string::npos)
|
||||||
throw std::runtime_error("Player has unresolved shared library dependencies");
|
throw std::runtime_error("Player has unresolved shared library dependencies");
|
||||||
// SDL/physics/gameplay are linked statically. glibc/libstdc++/Vulkan are the host baseline.
|
// SDL/physics/gameplay are linked statically. glibc/libstdc++/Vulkan are the host baseline.
|
||||||
@@ -427,8 +429,8 @@ struct BuildService::Impl {
|
|||||||
fs::rename(staging, directory);
|
fs::rename(staging, directory);
|
||||||
atomic_write_json(config.cache_root / "last_cook.json", {{"generation", digest}});
|
atomic_write_json(config.cache_root / "last_cook.json", {{"generation", digest}});
|
||||||
return {{"generation", digest},
|
return {{"generation", digest},
|
||||||
{"scene", (directory / "scene.fscene").string()},
|
{"scene", path_to_utf8(directory / "scene.fscene")},
|
||||||
{"directory", directory.string()}};
|
{"directory", path_to_utf8(directory)}};
|
||||||
} catch (...) {
|
} catch (...) {
|
||||||
std::error_code error;
|
std::error_code error;
|
||||||
fs::remove_all(staging, error);
|
fs::remove_all(staging, error);
|
||||||
@@ -451,13 +453,14 @@ struct BuildService::Impl {
|
|||||||
for (const auto& entry : fs::directory_iterator(source)) {
|
for (const auto& entry : fs::directory_iterator(source)) {
|
||||||
if (!entry.is_regular_file())
|
if (!entry.is_regular_file())
|
||||||
continue;
|
continue;
|
||||||
auto filename = entry.path().filename().string();
|
auto filename = path_to_utf8(entry.path().filename());
|
||||||
std::string upper = filename;
|
std::string upper = filename;
|
||||||
for (auto& c : upper)
|
for (auto& c : upper)
|
||||||
c = static_cast<char>(std::toupper(static_cast<unsigned char>(c)));
|
c = static_cast<char>(std::toupper(static_cast<unsigned char>(c)));
|
||||||
if (upper.starts_with("LICENSE") || upper.starts_with("COPYING") ||
|
if (upper.starts_with("LICENSE") || upper.starts_with("COPYING") ||
|
||||||
upper.starts_with("NOTICE")) {
|
upper.starts_with("NOTICE")) {
|
||||||
copy_required_file(entry.path(), destination / name / filename);
|
copy_required_file(entry.path(),
|
||||||
|
destination / name / entry.path().filename());
|
||||||
copied = true;
|
copied = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -487,7 +490,7 @@ struct BuildService::Impl {
|
|||||||
"generations" / generation);
|
"generations" / generation);
|
||||||
auto target = destination / "assets" / id / "generations" / generation;
|
auto target = destination / "assets" / id / "generations" / generation;
|
||||||
for (const auto& file : manifest.at("files")) {
|
for (const auto& file : manifest.at("files")) {
|
||||||
auto relative = fs::path(file.at("path").get<std::string>());
|
auto relative = path_from_utf8(file.at("path").get<std::string>());
|
||||||
copy_required_file(project_path(source_directory, relative),
|
copy_required_file(project_path(source_directory, relative),
|
||||||
project_path(target, relative));
|
project_path(target, relative));
|
||||||
}
|
}
|
||||||
@@ -508,7 +511,8 @@ struct BuildService::Impl {
|
|||||||
for (const auto& id : asset_references(job.scene))
|
for (const auto& id : asset_references(job.scene))
|
||||||
job.asset_manifests[id] = source.current_manifest(id);
|
job.asset_manifests[id] = source.current_manifest(id);
|
||||||
const auto built = build(job, true);
|
const auto built = build(job, true);
|
||||||
validate_component_types(job.scene, read_json(built.at("schema").get<std::string>()));
|
validate_component_types(job.scene,
|
||||||
|
read_json(path_from_utf8(built.at("schema").get<std::string>())));
|
||||||
auto output = fs::absolute(job.output);
|
auto output = fs::absolute(job.output);
|
||||||
if (output.empty())
|
if (output.empty())
|
||||||
throw std::runtime_error("An export destination is required");
|
throw std::runtime_error("An export destination is required");
|
||||||
@@ -519,7 +523,7 @@ struct BuildService::Impl {
|
|||||||
try {
|
try {
|
||||||
checkpoint(job, "Cooking export snapshot", .72);
|
checkpoint(job, "Cooking export snapshot", .72);
|
||||||
write_cooked_scene(staging / "scene.fscene", job.scene);
|
write_cooked_scene(staging / "scene.fscene", job.scene);
|
||||||
auto build_directory = fs::path(built.at("directory").get<std::string>());
|
auto build_directory = path_from_utf8(built.at("directory").get<std::string>());
|
||||||
copy_required_file(build_directory / ("faset_player" + executable_suffix()),
|
copy_required_file(build_directory / ("faset_player" + executable_suffix()),
|
||||||
staging / ("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",
|
||||||
@@ -528,7 +532,7 @@ struct BuildService::Impl {
|
|||||||
copy_required_file(build_directory / "shaders" / shader,
|
copy_required_file(build_directory / "shaders" / shader,
|
||||||
staging / "shaders" / shader);
|
staging / "shaders" / shader);
|
||||||
for (const auto& entry : fs::directory_iterator(build_directory)) {
|
for (const auto& entry : fs::directory_iterator(build_directory)) {
|
||||||
auto extension = entry.path().extension().string();
|
auto extension = path_to_utf8(entry.path().extension());
|
||||||
for (auto& c : extension)
|
for (auto& c : extension)
|
||||||
c = static_cast<char>(std::tolower(static_cast<unsigned char>(c)));
|
c = static_cast<char>(std::tolower(static_cast<unsigned char>(c)));
|
||||||
if (entry.is_regular_file() && extension == ".dll")
|
if (entry.is_regular_file() && extension == ".dll")
|
||||||
@@ -536,7 +540,8 @@ struct BuildService::Impl {
|
|||||||
}
|
}
|
||||||
checkpoint(job, "Packaging assets and notices", .80);
|
checkpoint(job, "Packaging assets and notices", .80);
|
||||||
package_assets(job, staging);
|
package_assets(job, staging);
|
||||||
package_notices(staging / "Notices", built.at("build_directory").get<std::string>());
|
package_notices(staging / "Notices",
|
||||||
|
path_from_utf8(built.at("build_directory").get<std::string>()));
|
||||||
atomic_write(
|
atomic_write(
|
||||||
staging / "README.txt",
|
staging / "README.txt",
|
||||||
"Run faset_player" + executable_suffix() +
|
"Run faset_player" + executable_suffix() +
|
||||||
@@ -553,8 +558,9 @@ struct BuildService::Impl {
|
|||||||
checkpoint(job, "Validating packaged Player", .90);
|
checkpoint(job, "Validating packaged Player", .90);
|
||||||
auto validation_output =
|
auto validation_output =
|
||||||
run(job,
|
run(job,
|
||||||
{(staging / ("faset_player" + executable_suffix())).string(), "--validate",
|
{path_to_utf8(staging / ("faset_player" + executable_suffix())), "--validate",
|
||||||
"--scene", (staging / "scene.fscene").string(), "--assets", staging.string()},
|
"--scene", path_to_utf8(staging / "scene.fscene"), "--assets",
|
||||||
|
path_to_utf8(staging)},
|
||||||
staging);
|
staging);
|
||||||
Json files = Json::array();
|
Json files = Json::array();
|
||||||
for (const auto& entry : fs::recursive_directory_iterator(staging)) {
|
for (const auto& entry : fs::recursive_directory_iterator(staging)) {
|
||||||
@@ -562,7 +568,7 @@ struct BuildService::Impl {
|
|||||||
throw std::runtime_error("Export contains a symlink");
|
throw std::runtime_error("Export contains a symlink");
|
||||||
if (entry.is_regular_file())
|
if (entry.is_regular_file())
|
||||||
files.push_back(
|
files.push_back(
|
||||||
{{"path", entry.path().lexically_relative(staging).generic_string()},
|
{{"path", generic_path_to_utf8(entry.path().lexically_relative(staging))},
|
||||||
{"sha256", sha256_file(entry.path())},
|
{"sha256", sha256_file(entry.path())},
|
||||||
{"size", entry.file_size()}});
|
{"size", entry.file_size()}});
|
||||||
}
|
}
|
||||||
@@ -613,15 +619,16 @@ struct BuildService::Impl {
|
|||||||
{"version", 1},
|
{"version", 1},
|
||||||
{"generation", job.status.id},
|
{"generation", job.status.id},
|
||||||
{"directory", "generations/" + job.status.id}});
|
{"directory", "generations/" + job.status.id}});
|
||||||
return {{"directory", generation.string()},
|
return {
|
||||||
{"executable", (generation / ("faset_player" + executable_suffix())).string()},
|
{"directory", path_to_utf8(generation)},
|
||||||
{"manifest", (generation / "manifest.json").string()},
|
{"executable", path_to_utf8(generation / ("faset_player" + executable_suffix()))},
|
||||||
{"generation", job.status.id},
|
{"manifest", path_to_utf8(generation / "manifest.json")},
|
||||||
{"build", built},
|
{"generation", job.status.id},
|
||||||
{"schema", built.at("schema")},
|
{"build", built},
|
||||||
{"player", built.at("player")},
|
{"schema", built.at("schema")},
|
||||||
{"build_directory", built.at("build_directory")},
|
{"player", built.at("player")},
|
||||||
{"configuration", built.at("configuration")}};
|
{"build_directory", built.at("build_directory")},
|
||||||
|
{"configuration", built.at("configuration")}};
|
||||||
} catch (...) {
|
} catch (...) {
|
||||||
std::error_code error;
|
std::error_code error;
|
||||||
fs::remove_all(staging, error);
|
fs::remove_all(staging, error);
|
||||||
|
|||||||
@@ -68,9 +68,9 @@ Json Commands::call(const std::string& name, const Json& arguments) {
|
|||||||
Json Commands::resolved_scene(const std::string& id) const {
|
Json Commands::resolved_scene(const std::string& id) const {
|
||||||
const auto result = authoring::resolve_templates(
|
const auto result = authoring::resolve_templates(
|
||||||
authoring_.query(id).at("scene"), authoring_.schemas(), [&](const std::string& path) {
|
authoring_.query(id).at("scene"), authoring_.schemas(), [&](const std::string& path) {
|
||||||
const auto relative = std::filesystem::path(path).lexically_normal();
|
const auto relative = path_from_utf8(path).lexically_normal();
|
||||||
for (const auto& document : authoring_.documents())
|
for (const auto& document : authoring_.documents())
|
||||||
if (document.at("path") == relative.generic_string())
|
if (document.at("path") == generic_path_to_utf8(relative))
|
||||||
return authoring_.query(document.at("id")).at("scene");
|
return authoring_.query(document.at("id")).at("scene");
|
||||||
return read_json(project_path(authoring_.root(), relative));
|
return read_json(project_path(authoring_.root(), relative));
|
||||||
});
|
});
|
||||||
@@ -92,7 +92,7 @@ Commands::Commands(authoring::AuthoringService& authoring) : authoring_(authorin
|
|||||||
"Open a scene relative to the project. Set recover=true to load its saved recovery "
|
"Open a scene relative to the project. Set recover=true to load its saved recovery "
|
||||||
"journal.",
|
"journal.",
|
||||||
object_schema({{"path", text}, {"recover", boolean}}, {"path"}), [&](const Json& args) {
|
object_schema({{"path", text}, {"recover", boolean}}, {"path"}), [&](const Json& args) {
|
||||||
return authoring_.open(args.at("path").get<std::string>(),
|
return authoring_.open(path_from_utf8(args.at("path").get<std::string>()),
|
||||||
args.value("recover", false));
|
args.value("recover", false));
|
||||||
});
|
});
|
||||||
add(
|
add(
|
||||||
@@ -104,7 +104,8 @@ Commands::Commands(authoring::AuthoringService& authoring) : authoring_(authorin
|
|||||||
add("faset_document_save",
|
add("faset_document_save",
|
||||||
"Atomically save an authoring document. Refuses to overwrite an externally modified file.",
|
"Atomically save an authoring document. Refuses to overwrite an externally modified file.",
|
||||||
object_schema({{"document", text}, {"path", text}}, {"document"}), [&](const Json& args) {
|
object_schema({{"document", text}, {"path", text}}, {"document"}), [&](const Json& args) {
|
||||||
return authoring_.save(args.at("document"), args.value("path", std::string()));
|
return authoring_.save(args.at("document"),
|
||||||
|
path_from_utf8(args.value("path", std::string())));
|
||||||
});
|
});
|
||||||
add(
|
add(
|
||||||
"faset_schema",
|
"faset_schema",
|
||||||
|
|||||||
+203
-67
@@ -174,6 +174,7 @@ struct EditorUI::Impl {
|
|||||||
std::uint64_t shown_revision = std::numeric_limits<std::uint64_t>::max();
|
std::uint64_t shown_revision = std::numeric_limits<std::uint64_t>::max();
|
||||||
std::map<std::string, std::uint64_t> edit_revisions;
|
std::map<std::string, std::uint64_t> edit_revisions;
|
||||||
std::map<std::string, Json> preview_fields;
|
std::map<std::string, Json> preview_fields;
|
||||||
|
std::map<std::string, std::string> import_retries;
|
||||||
std::filesystem::path layout_path;
|
std::filesystem::path layout_path;
|
||||||
std::filesystem::path theme_source_path, layout_source_path;
|
std::filesystem::path theme_source_path, layout_source_path;
|
||||||
std::string attempted_theme, attempted_layout, applied_theme, applied_layout,
|
std::string attempted_theme, attempted_layout, applied_theme, applied_layout,
|
||||||
@@ -196,6 +197,14 @@ struct EditorUI::Impl {
|
|||||||
std::uint64_t gizmo_revision = 0;
|
std::uint64_t gizmo_revision = 0;
|
||||||
std::string gizmo_component;
|
std::string gizmo_component;
|
||||||
ui::Rect viewport;
|
ui::Rect viewport;
|
||||||
|
float ui_scale = 1;
|
||||||
|
unsigned layout_width = 0, layout_height = 0;
|
||||||
|
float logical_width() const {
|
||||||
|
return float(renderer.width()) / ui_scale;
|
||||||
|
}
|
||||||
|
float logical_height() const {
|
||||||
|
return float(renderer.height()) / ui_scale;
|
||||||
|
}
|
||||||
Vec3 gizmo_origin{};
|
Vec3 gizmo_origin{};
|
||||||
render::Vec2 gizmo_drag_start{}, gizmo_drag_end{};
|
render::Vec2 gizmo_drag_start{}, gizmo_drag_end{};
|
||||||
float gizmo_world_length = 1;
|
float gizmo_world_length = 1;
|
||||||
@@ -253,12 +262,13 @@ struct EditorUI::Impl {
|
|||||||
document = session.authoring()
|
document = session.authoring()
|
||||||
.create("Untitled", session.project().value("dimension", 3))
|
.create("Untitled", session.project().value("dimension", 3))
|
||||||
.at("id");
|
.at("id");
|
||||||
|
ui_scale = std::clamp(renderer.display_scale(), .5f, 4.f);
|
||||||
refresh();
|
refresh();
|
||||||
}
|
}
|
||||||
void persist_layout() {
|
void persist_layout() {
|
||||||
dock.set_size("scene", ui.find("scene_panel")->rect.width);
|
dock.set_size("scene", ui.find("scene_panel")->rect.width / ui_scale);
|
||||||
dock.set_size("inspector", ui.find("inspector_panel")->rect.width);
|
dock.set_size("inspector", ui.find("inspector_panel")->rect.width / ui_scale);
|
||||||
dock.set_size("bottom", ui.find("bottom_panel")->rect.height);
|
dock.set_size("bottom", ui.find("bottom_panel")->rect.height / ui_scale);
|
||||||
try {
|
try {
|
||||||
dock.save(layout_path);
|
dock.save(layout_path);
|
||||||
} catch (const std::exception& e) {
|
} catch (const std::exception& e) {
|
||||||
@@ -897,7 +907,8 @@ struct EditorUI::Impl {
|
|||||||
}
|
}
|
||||||
if (!open)
|
if (!open)
|
||||||
try {
|
try {
|
||||||
source_scene = read_json(project_path(session.config().project_root, source));
|
source_scene = read_json(
|
||||||
|
project_path(session.config().project_root, path_from_utf8(source)));
|
||||||
} catch (...) {
|
} catch (...) {
|
||||||
return source;
|
return source;
|
||||||
}
|
}
|
||||||
@@ -983,7 +994,8 @@ struct EditorUI::Impl {
|
|||||||
}
|
}
|
||||||
void instance_scene(const std::string& path) {
|
void instance_scene(const std::string& path) {
|
||||||
try {
|
try {
|
||||||
auto data = read_json(project_path(session.config().project_root, path));
|
auto data =
|
||||||
|
read_json(project_path(session.config().project_root, path_from_utf8(path)));
|
||||||
authoring::validate_scene(data, session.authoring().schemas());
|
authoring::validate_scene(data, session.authoring().schemas());
|
||||||
if (data.at("id") == document)
|
if (data.at("id") == document)
|
||||||
throw std::runtime_error("A scene cannot instance itself");
|
throw std::runtime_error("A scene cannot instance itself");
|
||||||
@@ -1019,7 +1031,7 @@ struct EditorUI::Impl {
|
|||||||
subtree.insert(item.at("id").get<std::string>()).second || changed;
|
subtree.insert(item.at("id").get<std::string>()).second || changed;
|
||||||
}
|
}
|
||||||
auto result = call("faset_document_create",
|
auto result = call("faset_document_create",
|
||||||
{{"name", std::filesystem::path(path).stem().string()},
|
{{"name", path_to_utf8(path_from_utf8(path).stem())},
|
||||||
{"dimension", current.at("scene").value("dimension", 3)}});
|
{"dimension", current.at("scene").value("dimension", 3)}});
|
||||||
if (result.is_null())
|
if (result.is_null())
|
||||||
return;
|
return;
|
||||||
@@ -1075,9 +1087,9 @@ struct EditorUI::Impl {
|
|||||||
const bool image = manifest.is_object() && manifest.value("kind", std::string()) == "image";
|
const bool image = manifest.is_object() && manifest.value("kind", std::string()) == "image";
|
||||||
const auto name =
|
const auto name =
|
||||||
manifest.is_object()
|
manifest.is_object()
|
||||||
? std::filesystem::path(manifest.value("source", std::string("Imported asset")))
|
? path_to_utf8(
|
||||||
.stem()
|
path_from_utf8(manifest.value("source", std::string("Imported asset")))
|
||||||
.string()
|
.stem())
|
||||||
: "Imported asset";
|
: "Imported asset";
|
||||||
auto object = authoring::make_entity(session.authoring().schemas(), name);
|
auto object = authoring::make_entity(session.authoring().schemas(), name);
|
||||||
const auto entity_id = object.at("id").get<std::string>();
|
const auto entity_id = object.at("id").get<std::string>();
|
||||||
@@ -1218,7 +1230,7 @@ struct EditorUI::Impl {
|
|||||||
std::function<void(Json, int)> append_group = [&](Json path, int depth) {
|
std::function<void(Json, int)> append_group = [&](Json path, int depth) {
|
||||||
const auto source = instance_source(path);
|
const auto source = instance_source(path);
|
||||||
auto& row = tree.add(Kind::TreeRow, "instance-" + path.dump(),
|
auto& row = tree.add(Kind::TreeRow, "instance-" + path.dump(),
|
||||||
"[T] " + std::filesystem::path(source).filename().string());
|
"[T] " + path_to_utf8(path_from_utf8(source).filename()));
|
||||||
row.indent = depth;
|
row.indent = depth;
|
||||||
row.selected = instance_selection == path;
|
row.selected = instance_selection == path;
|
||||||
row.on_click = [this, path](Widget&) { select_instance(path); };
|
row.on_click = [this, path](Widget&) { select_instance(path); };
|
||||||
@@ -1378,7 +1390,7 @@ struct EditorUI::Impl {
|
|||||||
const auto path = e->at("origin").at("path");
|
const auto path = e->at("origin").at("path");
|
||||||
const auto object = e->at("origin").at("object").get<std::string>();
|
const auto object = e->at("origin").at("object").get<std::string>();
|
||||||
label(body, "object-origin",
|
label(body, "object-origin",
|
||||||
"Source: " + std::filesystem::path(instance_source(path)).filename().string());
|
"Source: " + path_to_utf8(path_from_utf8(instance_source(path)).filename()));
|
||||||
keep.insert("object-origin");
|
keep.insert("object-origin");
|
||||||
button(body, "object-open-source", "Open source",
|
button(body, "object-open-source", "Open source",
|
||||||
[this, path, object] { open_template_source(path, object); });
|
[this, path, object] { open_template_source(path, object); });
|
||||||
@@ -1565,9 +1577,8 @@ struct EditorUI::Impl {
|
|||||||
break;
|
break;
|
||||||
if (!entry.is_regular_file())
|
if (!entry.is_regular_file())
|
||||||
continue;
|
continue;
|
||||||
auto relative =
|
auto relative = generic_path_to_utf8(
|
||||||
std::filesystem::relative(entry.path(), session.config().project_root)
|
std::filesystem::relative(entry.path(), session.config().project_root));
|
||||||
.generic_string();
|
|
||||||
if (relative.find(".faset-") != std::string::npos)
|
if (relative.find(".faset-") != std::string::npos)
|
||||||
continue;
|
continue;
|
||||||
if (!asset_filter.empty() && relative.find(asset_filter) == std::string::npos)
|
if (!asset_filter.empty() && relative.find(asset_filter) == std::string::npos)
|
||||||
@@ -1602,11 +1613,10 @@ struct EditorUI::Impl {
|
|||||||
}
|
}
|
||||||
for (const auto& asset : assets) {
|
for (const auto& asset : assets) {
|
||||||
const auto id = asset.at("id").get<std::string>();
|
const auto id = asset.at("id").get<std::string>();
|
||||||
const auto name = asset.contains("manifest")
|
const auto name =
|
||||||
? std::filesystem::path(asset["manifest"].value("source", id))
|
asset.contains("manifest")
|
||||||
.filename()
|
? path_to_utf8(path_from_utf8(asset["manifest"].value("source", id)).filename())
|
||||||
.string()
|
: id;
|
||||||
: id;
|
|
||||||
auto& row = list.add(Kind::TreeRow, "asset-" + id, "Imported / " + name);
|
auto& row = list.add(Kind::TreeRow, "asset-" + id, "Imported / " + name);
|
||||||
row.layout.height = 25;
|
row.layout.height = 25;
|
||||||
row.indent = 1;
|
row.indent = 1;
|
||||||
@@ -1624,6 +1634,8 @@ struct EditorUI::Impl {
|
|||||||
trim_children(list, keep);
|
trim_children(list, keep);
|
||||||
}
|
}
|
||||||
void refresh_bottom() {
|
void refresh_bottom() {
|
||||||
|
const auto jobs_response = call("faset_jobs");
|
||||||
|
const auto all_jobs = jobs_response.is_null() ? Json::array() : jobs_response.at("jobs");
|
||||||
for (const auto& panel : session.plugin_panels()) {
|
for (const auto& panel : session.plugin_panels()) {
|
||||||
const auto panel_id = panel.at("id").get<std::string>();
|
const auto panel_id = panel.at("id").get<std::string>();
|
||||||
const auto dock_id = "plugin-" + panel_id;
|
const auto dock_id = "plugin-" + panel_id;
|
||||||
@@ -1674,8 +1686,6 @@ struct EditorUI::Impl {
|
|||||||
ui.find("console-items")->visible = active_bottom == "console";
|
ui.find("console-items")->visible = active_bottom == "console";
|
||||||
ui.find("job-items")->visible = active_bottom == "jobs";
|
ui.find("job-items")->visible = active_bottom == "jobs";
|
||||||
ui.find("conflict-items")->visible = active_bottom == "conflicts";
|
ui.find("conflict-items")->visible = active_bottom == "conflicts";
|
||||||
ui.find("tab-conflicts")->text =
|
|
||||||
"Conflicts (" + std::to_string(template_conflicts.size()) + ")";
|
|
||||||
auto& conflict_list = *ui.find("conflict-items");
|
auto& conflict_list = *ui.find("conflict-items");
|
||||||
std::set<std::string> conflict_keep;
|
std::set<std::string> conflict_keep;
|
||||||
std::size_t conflict_index = 0;
|
std::size_t conflict_index = 0;
|
||||||
@@ -1687,7 +1697,7 @@ struct EditorUI::Impl {
|
|||||||
const auto path = conflict.at("instance_path");
|
const auto path = conflict.at("instance_path");
|
||||||
auto& text = row.add(
|
auto& text = row.add(
|
||||||
Kind::Label, id + "-text",
|
Kind::Label, id + "-text",
|
||||||
code + " · " + std::filesystem::path(instance_source(path)).filename().string());
|
code + " · " + path_to_utf8(path_from_utf8(instance_source(path)).filename()));
|
||||||
text.layout.flex = 1;
|
text.layout.flex = 1;
|
||||||
button(
|
button(
|
||||||
row, id + "-source", "Open source", [this, path] { open_template_source(path); },
|
row, id + "-source", "Open source", [this, path] { open_template_source(path); },
|
||||||
@@ -1716,9 +1726,113 @@ struct EditorUI::Impl {
|
|||||||
}
|
}
|
||||||
conflict_keep.insert(id);
|
conflict_keep.insert(id);
|
||||||
}
|
}
|
||||||
|
std::size_t import_conflicts = 0;
|
||||||
|
for (const auto& job : all_jobs) {
|
||||||
|
if (job.value("kind", std::string()) != "import" ||
|
||||||
|
job.value("state", std::string()) != "conflict")
|
||||||
|
continue;
|
||||||
|
const auto job_id = job.at("id").get<std::string>();
|
||||||
|
const auto& result = job.at("result");
|
||||||
|
const auto request = job.value("request", Json::object());
|
||||||
|
bool pending = false, superseded = false;
|
||||||
|
std::string retry_error;
|
||||||
|
if (const auto found = import_retries.find(job_id); found != import_retries.end())
|
||||||
|
for (const auto& retry : all_jobs)
|
||||||
|
if (retry.at("id") == found->second) {
|
||||||
|
const auto state = retry.value("state", std::string());
|
||||||
|
pending = state == "queued" || state == "running";
|
||||||
|
superseded = state == "succeeded" || state == "conflict";
|
||||||
|
if (state == "failed" || state == "cancelled")
|
||||||
|
retry_error = retry.value("error", std::string("Retry cancelled"));
|
||||||
|
}
|
||||||
|
for (const auto& asset : assets)
|
||||||
|
if (asset.at("id") == result.value("asset_id", std::string()) &&
|
||||||
|
asset.contains("manifest") &&
|
||||||
|
asset.at("manifest").at("generation") ==
|
||||||
|
result.value("generation", std::string()))
|
||||||
|
superseded = true;
|
||||||
|
if (superseded)
|
||||||
|
continue;
|
||||||
|
++import_conflicts;
|
||||||
|
const auto id = "import-conflict-" + job_id;
|
||||||
|
auto& group = conflict_list.add(Kind::Column, id);
|
||||||
|
group.layout.padding = 6;
|
||||||
|
group.layout.gap = 3;
|
||||||
|
conflict_keep.insert(id);
|
||||||
|
label(group, id + "-title", "Import removal: " + request.value("path", job_id));
|
||||||
|
label(group, id + "-note",
|
||||||
|
"The previous asset stays active. Update affected references before accepting.");
|
||||||
|
label(group, id + "-generation",
|
||||||
|
"Reviewed generation: " + result.value("generation", std::string()));
|
||||||
|
auto diagnostic = job.value("error", std::string());
|
||||||
|
std::replace(diagnostic.begin(), diagnostic.end(), '\n', ' ');
|
||||||
|
label(group, id + "-diagnostic", diagnostic);
|
||||||
|
const auto removed = result.value("removed_output_ids", Json::array());
|
||||||
|
auto& outputs = group.add(Kind::Column, id + "-outputs");
|
||||||
|
outputs.layout.height = std::min(150.f, std::max(26.f, float(removed.size()) * 26));
|
||||||
|
outputs.layout.scroll = true;
|
||||||
|
outputs.layout.gap = 0;
|
||||||
|
std::set<std::string> output_keep;
|
||||||
|
for (const auto& output : removed) {
|
||||||
|
const auto output_id = output.get<std::string>();
|
||||||
|
std::string name;
|
||||||
|
for (const auto& asset : assets)
|
||||||
|
if (asset.at("id") == result.value("asset_id", std::string()) &&
|
||||||
|
asset.contains("manifest"))
|
||||||
|
for (const auto* collection : {"nodes", "meshes", "materials", "textures"})
|
||||||
|
for (const auto& item :
|
||||||
|
asset.at("manifest").value(collection, Json::array()))
|
||||||
|
if (item.at("id") == output_id)
|
||||||
|
name = item.value("name", std::string());
|
||||||
|
const auto output_widget = id + "-output-" + output_id;
|
||||||
|
label(outputs, output_widget, output_id + (name.empty() ? "" : " / " + name));
|
||||||
|
outputs.find(output_widget)->layout.height = 26;
|
||||||
|
output_keep.insert(output_widget);
|
||||||
|
}
|
||||||
|
trim_children(outputs, output_keep);
|
||||||
|
auto& actions = group.add(Kind::Row, id + "-actions");
|
||||||
|
actions.layout.height = 30;
|
||||||
|
const auto retry = [this, job_id, request, result](bool accept) {
|
||||||
|
Json arguments = {{"path", request.at("path")}, {"allow_removed_outputs", accept}};
|
||||||
|
if (request.contains("settings") && !request.at("settings").is_null())
|
||||||
|
arguments["settings"] = request.at("settings");
|
||||||
|
if (accept) {
|
||||||
|
arguments["expected_generation"] = result.at("generation");
|
||||||
|
arguments["expected_active_generation"] = result.at("previous_generation");
|
||||||
|
}
|
||||||
|
auto submitted = call("faset_import", arguments);
|
||||||
|
if (!submitted.is_null()) {
|
||||||
|
import_retries[job_id] = submitted.at("job");
|
||||||
|
status =
|
||||||
|
accept
|
||||||
|
? "Publishing the reviewed import; source changes require a new review"
|
||||||
|
: "Reimporting source for a fresh review";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
button(
|
||||||
|
actions, id + "-retry", "Reimport / review again", [retry] { retry(false); }, 185)
|
||||||
|
.enabled = !pending && request.contains("path");
|
||||||
|
button(
|
||||||
|
actions, id + "-accept", "Accept reviewed removal", [retry] { retry(true); }, 190)
|
||||||
|
.enabled = !pending && request.contains("path") && !removed.empty();
|
||||||
|
button(
|
||||||
|
actions, id + "-copy", "Copy removed IDs",
|
||||||
|
[this, removed] { renderer.set_clipboard(removed.dump(2)); }, 145);
|
||||||
|
auto detail = pending ? "Import in progress; the previous generation remains active."
|
||||||
|
: "Accepting removes these outputs. Scene references are not "
|
||||||
|
"remapped automatically.";
|
||||||
|
label(group, id + "-detail", detail);
|
||||||
|
if (!retry_error.empty()) {
|
||||||
|
std::replace(retry_error.begin(), retry_error.end(), '\n', ' ');
|
||||||
|
label(group, id + "-error", retry_error);
|
||||||
|
} else
|
||||||
|
group.remove(id + "-error");
|
||||||
|
}
|
||||||
|
ui.find("tab-conflicts")->text =
|
||||||
|
"Conflicts (" + std::to_string(template_conflicts.size() + import_conflicts) + ")";
|
||||||
if (conflict_keep.empty()) {
|
if (conflict_keep.empty()) {
|
||||||
label(conflict_list, "conflicts-empty",
|
label(conflict_list, "conflicts-empty",
|
||||||
"No template conflicts. Missing targets preserve their overrides here.");
|
"No template or import conflicts. Missing targets preserve their data here.");
|
||||||
conflict_keep.insert("conflicts-empty");
|
conflict_keep.insert("conflicts-empty");
|
||||||
}
|
}
|
||||||
trim_children(conflict_list, conflict_keep);
|
trim_children(conflict_list, conflict_keep);
|
||||||
@@ -1738,27 +1852,28 @@ struct EditorUI::Impl {
|
|||||||
keep.insert("log-empty");
|
keep.insert("log-empty");
|
||||||
}
|
}
|
||||||
trim_children(console, keep);
|
trim_children(console, keep);
|
||||||
auto result = call("faset_jobs");
|
|
||||||
auto& jobs = *ui.find("job-items");
|
auto& jobs = *ui.find("job-items");
|
||||||
keep.clear();
|
keep.clear();
|
||||||
if (!result.is_null())
|
for (const auto& job : all_jobs) {
|
||||||
for (const auto& job : result.at("jobs")) {
|
const auto id = job.at("id").get<std::string>();
|
||||||
const auto id = job.at("id").get<std::string>();
|
auto& row = jobs.add(Kind::Row, "job-" + id);
|
||||||
auto& row = jobs.add(Kind::Row, "job-" + id);
|
row.layout.height = 28;
|
||||||
row.layout.height = 28;
|
keep.insert(row.id);
|
||||||
keep.insert(row.id);
|
const auto state = job.value("state", std::string());
|
||||||
const auto state = job.value("state", std::string());
|
auto& text = row.add(Kind::Label, "job-text-" + id,
|
||||||
auto& text =
|
job.value("kind", std::string("Job")) + " · " + state + " · " +
|
||||||
row.add(Kind::Label, "job-text-" + id,
|
job.value("stage", std::string()) + " " +
|
||||||
job.value("kind", std::string("Job")) + " · " + state + " · " +
|
std::to_string(int(job.value("progress", 0.0) * 100)) + "%");
|
||||||
job.value("stage", std::string()) + " " +
|
text.layout.flex = 1;
|
||||||
std::to_string(int(job.value("progress", 0.0) * 100)) + "%");
|
auto& cancel = button(
|
||||||
text.layout.flex = 1;
|
row, "job-cancel-" + id, "Cancel",
|
||||||
auto& cancel = button(
|
[this, id] { call("faset_job_cancel", {{"id", id}}); }, 72);
|
||||||
row, "job-cancel-" + id, "Cancel",
|
cancel.enabled = state == "queued" || state == "running";
|
||||||
[this, id] { call("faset_job_cancel", {{"id", id}}); }, 72);
|
auto& review = button(
|
||||||
cancel.enabled = state == "queued" || state == "running";
|
row, "job-review-" + id, "Review removals", [this] { active_bottom = "conflicts"; },
|
||||||
}
|
135);
|
||||||
|
review.visible = job.value("kind", std::string()) == "import" && state == "conflict";
|
||||||
|
}
|
||||||
if (keep.empty()) {
|
if (keep.empty()) {
|
||||||
label(jobs, "jobs-empty", "No active import, build or export jobs.");
|
label(jobs, "jobs-empty", "No active import, build or export jobs.");
|
||||||
keep.insert("jobs-empty");
|
keep.insert("jobs-empty");
|
||||||
@@ -1788,8 +1903,8 @@ struct EditorUI::Impl {
|
|||||||
: "Project switching is unavailable while MCP is connected";
|
: "Project switching is unavailable while MCP is connected";
|
||||||
auto* switching = ui.find("project-switch-dialog");
|
auto* switching = ui.find("project-switch-dialog");
|
||||||
switching->visible = project_switch_warning;
|
switching->visible = project_switch_warning;
|
||||||
switching->layout.x = std::max(0.f, (float(renderer.width()) - 500) * .5f);
|
switching->layout.x = std::max(0.f, (logical_width() - 500) * .5f);
|
||||||
switching->layout.y = std::max(0.f, (float(renderer.height()) - 220) * .5f);
|
switching->layout.y = std::max(0.f, (logical_height() - 220) * .5f);
|
||||||
for (const auto* id : {"menu-duplicate", "menu-delete"})
|
for (const auto* id : {"menu-duplicate", "menu-delete"})
|
||||||
ui.find(id)->visible = edit;
|
ui.find(id)->visible = edit;
|
||||||
for (const auto* id : {"help-one", "help-two", "help-three"})
|
for (const auto* id : {"help-one", "help-two", "help-three"})
|
||||||
@@ -1805,7 +1920,7 @@ struct EditorUI::Impl {
|
|||||||
: 5;
|
: 5;
|
||||||
auto& command = *ui.find("palette");
|
auto& command = *ui.find("palette");
|
||||||
command.visible = palette;
|
command.visible = palette;
|
||||||
command.layout.x = std::max(0.f, (float(renderer.width()) - 570) / 2);
|
command.layout.x = std::max(0.f, (logical_width() - 570) / 2);
|
||||||
command.layout.y = 90;
|
command.layout.y = 90;
|
||||||
auto& list = *ui.find("palette-list");
|
auto& list = *ui.find("palette-list");
|
||||||
std::set<std::string> keep;
|
std::set<std::string> keep;
|
||||||
@@ -1855,10 +1970,9 @@ struct EditorUI::Impl {
|
|||||||
if (++visited > 4000)
|
if (++visited > 4000)
|
||||||
break;
|
break;
|
||||||
if (entry.is_regular_file() &&
|
if (entry.is_regular_file() &&
|
||||||
entry.path().filename().string().ends_with(".scene.json"))
|
path_to_utf8(entry.path().filename()).ends_with(".scene.json"))
|
||||||
saved.insert(
|
saved.insert(generic_path_to_utf8(std::filesystem::relative(
|
||||||
std::filesystem::relative(entry.path(), session.config().project_root)
|
entry.path(), session.config().project_root)));
|
||||||
.generic_string());
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (const std::exception& error) {
|
} catch (const std::exception& error) {
|
||||||
@@ -1867,7 +1981,8 @@ struct EditorUI::Impl {
|
|||||||
std::size_t index = 0;
|
std::size_t index = 0;
|
||||||
for (const auto& path : saved) {
|
for (const auto& path : saved) {
|
||||||
try {
|
try {
|
||||||
const auto scene = read_json(project_path(session.config().project_root, path));
|
const auto scene =
|
||||||
|
read_json(project_path(session.config().project_root, path_from_utf8(path)));
|
||||||
if (scene.value("format", "") != "faset.scene" || scene.value("version", 0) != 1)
|
if (scene.value("format", "") != "faset.scene" || scene.value("version", 0) != 1)
|
||||||
continue;
|
continue;
|
||||||
} catch (...) {
|
} catch (...) {
|
||||||
@@ -1914,10 +2029,10 @@ struct EditorUI::Impl {
|
|||||||
void refresh_project_settings() {
|
void refresh_project_settings() {
|
||||||
auto* panel = ui.find("project-settings-panel");
|
auto* panel = ui.find("project-settings-panel");
|
||||||
panel->visible = project_settings_open;
|
panel->visible = project_settings_open;
|
||||||
panel->layout.width = std::min(610.f, std::max(340.f, float(renderer.width()) - 40));
|
panel->layout.width = std::min(610.f, std::max(340.f, logical_width() - 40));
|
||||||
panel->layout.height = std::min(550.f, std::max(300.f, float(renderer.height()) - 40));
|
panel->layout.height = std::min(550.f, std::max(300.f, logical_height() - 40));
|
||||||
panel->layout.x = std::max(0.f, (float(renderer.width()) - panel->layout.width) * .5f);
|
panel->layout.x = std::max(0.f, (logical_width() - panel->layout.width) * .5f);
|
||||||
panel->layout.y = std::max(0.f, (float(renderer.height()) - panel->layout.height) * .5f);
|
panel->layout.y = std::max(0.f, (logical_height() - panel->layout.height) * .5f);
|
||||||
ui.find("project-settings-2d")->selected = project_settings_dimension == 2;
|
ui.find("project-settings-2d")->selected = project_settings_dimension == 2;
|
||||||
ui.find("project-settings-3d")->selected = project_settings_dimension == 3;
|
ui.find("project-settings-3d")->selected = project_settings_dimension == 3;
|
||||||
ui.find("project-settings-error")->text = project_settings_error;
|
ui.find("project-settings-error")->text = project_settings_error;
|
||||||
@@ -1928,7 +2043,7 @@ struct EditorUI::Impl {
|
|||||||
void refresh_simulation() {
|
void refresh_simulation() {
|
||||||
auto& panel = *ui.find("simulation-panel");
|
auto& panel = *ui.find("simulation-panel");
|
||||||
panel.visible = simulation_open;
|
panel.visible = simulation_open;
|
||||||
panel.layout.x = std::max(0.f, (float(renderer.width()) - 470) / 2);
|
panel.layout.x = std::max(0.f, (logical_width() - 470) / 2);
|
||||||
panel.layout.y = 90;
|
panel.layout.y = 90;
|
||||||
if (!simulation_open)
|
if (!simulation_open)
|
||||||
return;
|
return;
|
||||||
@@ -1986,7 +2101,7 @@ struct EditorUI::Impl {
|
|||||||
void refresh_recovery() {
|
void refresh_recovery() {
|
||||||
auto& panel = *ui.find("recovery-panel");
|
auto& panel = *ui.find("recovery-panel");
|
||||||
panel.visible = !recovery.empty();
|
panel.visible = !recovery.empty();
|
||||||
panel.layout.x = std::max(0.f, (float(renderer.width()) - 470) / 2);
|
panel.layout.x = std::max(0.f, (logical_width() - 470) / 2);
|
||||||
panel.layout.y = 100;
|
panel.layout.y = 100;
|
||||||
std::set<std::string> keep;
|
std::set<std::string> keep;
|
||||||
label(panel, "recovery-title", "Unsaved authoring recovery");
|
label(panel, "recovery-title", "Unsaved authoring recovery");
|
||||||
@@ -2152,11 +2267,16 @@ struct EditorUI::Impl {
|
|||||||
const render::Color colors[3] = {
|
const render::Color colors[3] = {
|
||||||
{.88f, .35f, .34f, 1}, {.38f, .75f, .49f, 1}, {.4f, .58f, .92f, 1}};
|
{.88f, .35f, .34f, 1}, {.38f, .75f, .49f, 1}, {.4f, .58f, .92f, 1}};
|
||||||
for (int axis = 0; axis < (is2d ? 2 : 3); ++axis) {
|
for (int axis = 0; axis < (is2d ? 2 : 3); ++axis) {
|
||||||
line(gizmo_screen[0], gizmo_screen[axis + 1], colors[axis], 2);
|
line(gizmo_screen[0], gizmo_screen[axis + 1], colors[axis], 2 * ui_scale);
|
||||||
const auto end = gizmo_screen[axis + 1];
|
const auto end = gizmo_screen[axis + 1];
|
||||||
if (viewport.contains(end[0], end[1]))
|
if (viewport.contains(end[0], end[1]))
|
||||||
rendered.ui_quads.push_back(
|
rendered.ui_quads.push_back({end[0] - 4 * ui_scale,
|
||||||
{end[0] - 4, end[1] - 4, 8, 8, colors[axis], {}, {0, 0, 1, 1}});
|
end[1] - 4 * ui_scale,
|
||||||
|
8 * ui_scale,
|
||||||
|
8 * ui_scale,
|
||||||
|
colors[axis],
|
||||||
|
{},
|
||||||
|
{0, 0, 1, 1}});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2196,12 +2316,12 @@ struct EditorUI::Impl {
|
|||||||
const auto* c = component(*e, "faset.transform");
|
const auto* c = component(*e, "faset.transform");
|
||||||
if (!c || c->value("version", 1) != 1)
|
if (!c || c->value("version", 1) != 1)
|
||||||
return false;
|
return false;
|
||||||
float best = 8;
|
float best = 8 * ui_scale;
|
||||||
int axis = -1;
|
int axis = -1;
|
||||||
for (int i = 0; i < (current.at("scene").value("dimension", 3) == 2 ? 2 : 3); ++i) {
|
for (int i = 0; i < (current.at("scene").value("dimension", 3) == 2 ? 2 : 3); ++i) {
|
||||||
auto a = gizmo_screen[0], b = gizmo_screen[i + 1];
|
auto a = gizmo_screen[0], b = gizmo_screen[i + 1];
|
||||||
const auto dx = b[0] - a[0], dy = b[1] - a[1], l = dx * dx + dy * dy;
|
const auto dx = b[0] - a[0], dy = b[1] - a[1], l = dx * dx + dy * dy;
|
||||||
if (l < 16)
|
if (l < 16 * ui_scale * ui_scale)
|
||||||
continue;
|
continue;
|
||||||
const auto t = std::clamp(((x - a[0]) * dx + (y - a[1]) * dy) / l, 0.f, 1.f);
|
const auto t = std::clamp(((x - a[0]) * dx + (y - a[1]) * dy) / l, 0.f, 1.f);
|
||||||
const auto d = std::hypot(x - a[0] - t * dx, y - a[1] - t * dy);
|
const auto d = std::hypot(x - a[0] - t * dx, y - a[1] - t * dy);
|
||||||
@@ -2282,8 +2402,8 @@ struct EditorUI::Impl {
|
|||||||
if (camera_drag) {
|
if (camera_drag) {
|
||||||
const bool is2d = current.at("scene").value("dimension", 3) == 2;
|
const bool is2d = current.at("scene").value("dimension", 3) == 2;
|
||||||
if (camera_drag == 3 && !is2d) {
|
if (camera_drag == 3 && !is2d) {
|
||||||
yaw -= dx * .008f;
|
yaw -= dx / ui_scale * .008f;
|
||||||
pitch = std::clamp(pitch + dy * .008f, -1.5f, 1.5f);
|
pitch = std::clamp(pitch + dy / ui_scale * .008f, -1.5f, 1.5f);
|
||||||
} else {
|
} else {
|
||||||
const auto right = Vec3{std::cos(yaw), 0, -std::sin(yaw)};
|
const auto right = Vec3{std::cos(yaw), 0, -std::sin(yaw)};
|
||||||
const auto up =
|
const auto up =
|
||||||
@@ -2367,7 +2487,7 @@ struct EditorUI::Impl {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (event.type == Type::MouseDown && !menu.empty() &&
|
if (event.type == Type::MouseDown && !menu.empty() &&
|
||||||
!ui.find("menu-popup")->rect.contains(event.x, event.y) && event.y > 36)
|
!ui.find("menu-popup")->rect.contains(event.x, event.y) && event.y > 36 * ui_scale)
|
||||||
menu.clear();
|
menu.clear();
|
||||||
if (event.type == Type::MouseMove || event.type == Type::MouseDown) {
|
if (event.type == Type::MouseMove || event.type == Type::MouseDown) {
|
||||||
mouse_x = event.x;
|
mouse_x = event.x;
|
||||||
@@ -2427,15 +2547,31 @@ struct EditorUI::Impl {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
void frame(const std::vector<render::Event>& events_) {
|
void frame(const std::vector<render::Event>& events_) {
|
||||||
|
const auto next_scale = std::clamp(renderer.display_scale(), .5f, 4.f);
|
||||||
|
const bool geometry_changed = next_scale != ui_scale || layout_width != renderer.width() ||
|
||||||
|
layout_height != renderer.height();
|
||||||
|
if (next_scale != ui_scale) {
|
||||||
|
camera_drag = 0;
|
||||||
|
if (gizmo_axis >= 0) {
|
||||||
|
const auto field = gizmo_mode == "Move" ? "position"
|
||||||
|
: gizmo_mode == "Rotate" ? "rotation"
|
||||||
|
: "scale";
|
||||||
|
preview_fields.erase(gizmo_component + "/" + field);
|
||||||
|
gizmo_axis = -1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ui_scale = next_scale;
|
||||||
|
layout_width = renderer.width();
|
||||||
|
layout_height = renderer.height();
|
||||||
session.poll();
|
session.poll();
|
||||||
poll_presentation();
|
poll_presentation();
|
||||||
refresh();
|
refresh();
|
||||||
ui.layout(float(renderer.width()), float(renderer.height()));
|
ui.layout(float(renderer.width()), float(renderer.height()), ui_scale);
|
||||||
if (rendered.scene_rect[2] == 0)
|
if (rendered.scene_rect[2] == 0 || geometry_changed)
|
||||||
build_snapshot();
|
build_snapshot();
|
||||||
events(events_);
|
events(events_);
|
||||||
refresh();
|
refresh();
|
||||||
ui.layout(float(renderer.width()), float(renderer.height()));
|
ui.layout(float(renderer.width()), float(renderer.height()), ui_scale);
|
||||||
build_snapshot();
|
build_snapshot();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -202,7 +202,8 @@ struct PluginManager::Impl {
|
|||||||
module->owner = this;
|
module->owner = this;
|
||||||
module->manifest = manifest;
|
module->manifest = manifest;
|
||||||
module->directory = directory;
|
module->directory = directory;
|
||||||
const auto path = project_path(directory, manifest.at("library").get<std::string>());
|
const auto path =
|
||||||
|
project_path(directory, path_from_utf8(manifest.at("library").get<std::string>()));
|
||||||
require(std::filesystem::is_regular_file(path), "plugin.library",
|
require(std::filesystem::is_regular_file(path), "plugin.library",
|
||||||
"Plugin library is missing");
|
"Plugin library is missing");
|
||||||
#ifdef _WIN32
|
#ifdef _WIN32
|
||||||
@@ -307,8 +308,8 @@ void PluginManager::load(const std::filesystem::path& directory) {
|
|||||||
};
|
};
|
||||||
for (const auto& entry : std::filesystem::recursive_directory_iterator(directory))
|
for (const auto& entry : std::filesystem::recursive_directory_iterator(directory))
|
||||||
if (entry.is_regular_file() &&
|
if (entry.is_regular_file() &&
|
||||||
entry.path().filename().string().ends_with(".faset-plugin.json")) {
|
path_to_utf8(entry.path().filename()).ends_with(".faset-plugin.json")) {
|
||||||
std::string id = entry.path().filename().string();
|
std::string id = path_to_utf8(entry.path().filename());
|
||||||
try {
|
try {
|
||||||
const auto manifest = read_json(entry.path());
|
const auto manifest = read_json(entry.path());
|
||||||
id = manifest.at("id");
|
id = manifest.at("id");
|
||||||
@@ -324,7 +325,8 @@ void PluginManager::load(const std::filesystem::path& directory) {
|
|||||||
manifest.at("build_fingerprint") == fingerprint(),
|
manifest.at("build_fingerprint") == fingerprint(),
|
||||||
"plugin.compatibility",
|
"plugin.compatibility",
|
||||||
"Plugin manifest does not match this Editor SDK; rebuild it");
|
"Plugin manifest does not match this Editor SDK; rebuild it");
|
||||||
project_path(entry.path().parent_path(), manifest.at("library").get<std::string>());
|
project_path(entry.path().parent_path(),
|
||||||
|
path_from_utf8(manifest.at("library").get<std::string>()));
|
||||||
sources.emplace(id, Source{manifest, entry.path().parent_path()});
|
sources.emplace(id, Source{manifest, entry.path().parent_path()});
|
||||||
} catch (const std::exception& error) {
|
} catch (const std::exception& error) {
|
||||||
failure(id, error.what());
|
failure(id, error.what());
|
||||||
|
|||||||
+59
-21
@@ -34,7 +34,7 @@ struct Session::ImportTask {
|
|||||||
std::shared_ptr<assets::ImportJob> job = std::make_shared<assets::ImportJob>();
|
std::shared_ptr<assets::ImportJob> job = std::make_shared<assets::ImportJob>();
|
||||||
mutable std::mutex mutex;
|
mutable std::mutex mutex;
|
||||||
std::string state = "queued", error;
|
std::string state = "queued", error;
|
||||||
Json result = Json::object();
|
Json result = Json::object(), request = Json::object();
|
||||||
Json json() const {
|
Json json() const {
|
||||||
std::lock_guard lock(mutex);
|
std::lock_guard lock(mutex);
|
||||||
const auto progress = job->progress();
|
const auto progress = job->progress();
|
||||||
@@ -44,6 +44,7 @@ struct Session::ImportTask {
|
|||||||
{"stage", progress.stage},
|
{"stage", progress.stage},
|
||||||
{"progress", progress.fraction},
|
{"progress", progress.fraction},
|
||||||
{"error", error},
|
{"error", error},
|
||||||
|
{"request", request},
|
||||||
{"result", result}};
|
{"result", result}};
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -97,13 +98,13 @@ Json Session::project() const {
|
|||||||
"Project start_scene must be a relative path");
|
"Project start_scene must be a relative path");
|
||||||
const auto scene = value.at("start_scene").get<std::string>();
|
const auto scene = value.at("start_scene").get<std::string>();
|
||||||
if (!scene.empty())
|
if (!scene.empty())
|
||||||
project_path(config_.project_root, scene);
|
project_path(config_.project_root, path_from_utf8(scene));
|
||||||
}
|
}
|
||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
return {{"format", "faset.project"},
|
return {{"format", "faset.project"},
|
||||||
{"version", 1},
|
{"version", 1},
|
||||||
{"name", config_.project_root.filename().string()},
|
{"name", path_to_utf8(config_.project_root.filename())},
|
||||||
{"dimension", 3}};
|
{"dimension", 3}};
|
||||||
}
|
}
|
||||||
void Session::scaffold(const std::string& name, int dimension) {
|
void Session::scaffold(const std::string& name, int dimension) {
|
||||||
@@ -117,12 +118,12 @@ Json Session::assets_list() const {
|
|||||||
for (const auto& entry : std::filesystem::directory_iterator(directory))
|
for (const auto& entry : std::filesystem::directory_iterator(directory))
|
||||||
if (entry.is_directory()) {
|
if (entry.is_directory()) {
|
||||||
try {
|
try {
|
||||||
const auto id = entry.path().filename().string();
|
const auto id = path_to_utf8(entry.path().filename());
|
||||||
const auto manifest = assets_.current_manifest(id);
|
const auto manifest = assets_.current_manifest(id);
|
||||||
list.push_back({{"id", id}, {"manifest", manifest}});
|
list.push_back({{"id", id}, {"manifest", manifest}});
|
||||||
} catch (const std::exception& error) {
|
} catch (const std::exception& error) {
|
||||||
list.push_back(
|
list.push_back(
|
||||||
{{"id", entry.path().filename().string()}, {"error", error.what()}});
|
{{"id", path_to_utf8(entry.path().filename())}, {"error", error.what()}});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return {{"assets", list}};
|
return {{"assets", list}};
|
||||||
@@ -137,8 +138,8 @@ std::string Session::source_signature() const {
|
|||||||
std::sort(files.begin(), files.end());
|
std::sort(files.begin(), files.end());
|
||||||
std::string contents;
|
std::string contents;
|
||||||
for (const auto& file : files)
|
for (const auto& file : files)
|
||||||
contents +=
|
contents += generic_path_to_utf8(file.lexically_relative(directory)) + ":" +
|
||||||
file.lexically_relative(directory).generic_string() + ":" + sha256_file(file) + "\n";
|
sha256_file(file) + "\n";
|
||||||
return sha256(contents);
|
return sha256(contents);
|
||||||
}
|
}
|
||||||
Json Session::schema_status() const {
|
Json Session::schema_status() const {
|
||||||
@@ -181,8 +182,8 @@ void Session::launch_player(Json scene, const std::filesystem::path& executable)
|
|||||||
control_sequence_ = 0;
|
control_sequence_ = 0;
|
||||||
ProcessOptions options;
|
ProcessOptions options;
|
||||||
options.arguments = {
|
options.arguments = {
|
||||||
executable.string(), "--scene", snapshot.string(), "--assets",
|
path_to_utf8(executable), "--scene", path_to_utf8(snapshot), "--assets",
|
||||||
assets_.cache_root().string(), "--control", control_path_.string()};
|
path_to_utf8(assets_.cache_root()), "--control", path_to_utf8(control_path_)};
|
||||||
options.working_directory = config_.project_root;
|
options.working_directory = config_.project_root;
|
||||||
player_ = std::make_unique<Process>(options);
|
player_ = std::make_unique<Process>(options);
|
||||||
log("Play started in a separate Player process");
|
log("Play started in a separate Player process");
|
||||||
@@ -194,10 +195,34 @@ void Session::stop_player() {
|
|||||||
pending_play_scene_ = nullptr;
|
pending_play_scene_ = nullptr;
|
||||||
}
|
}
|
||||||
if (player_) {
|
if (player_) {
|
||||||
player_->cancel();
|
// Allow the normal Player shutdown path (including gameplay OnDestroy) first.
|
||||||
const auto result = player_->poll();
|
// Keep Stop synchronous and bounded so starting a new Play cannot overlap this one.
|
||||||
log(result.output);
|
bool finished = false;
|
||||||
|
try {
|
||||||
|
atomic_write_json(control_path_,
|
||||||
|
{{"sequence", ++control_sequence_}, {"command", "stop"}});
|
||||||
|
const auto deadline = std::chrono::steady_clock::now() + std::chrono::milliseconds(500);
|
||||||
|
for (;;) {
|
||||||
|
const auto result = player_->poll();
|
||||||
|
log(result.output);
|
||||||
|
if (!result.running) {
|
||||||
|
finished = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (std::chrono::steady_clock::now() >= deadline)
|
||||||
|
break;
|
||||||
|
std::this_thread::sleep_for(std::chrono::milliseconds(5));
|
||||||
|
}
|
||||||
|
} catch (const std::exception& error) {
|
||||||
|
log(std::string("Graceful Player stop failed: ") + error.what());
|
||||||
|
}
|
||||||
|
if (!finished) {
|
||||||
|
log("Player did not finish graceful shutdown; terminating the process");
|
||||||
|
player_->cancel();
|
||||||
|
log(player_->poll().output);
|
||||||
|
}
|
||||||
player_.reset();
|
player_.reset();
|
||||||
|
control_path_.clear();
|
||||||
log("Play stopped; authoring scene unchanged");
|
log("Play stopped; authoring scene unchanged");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -224,7 +249,7 @@ void Session::poll() {
|
|||||||
log(value.kind + " completed");
|
log(value.kind + " completed");
|
||||||
if (value.result.contains("schema"))
|
if (value.result.contains("schema"))
|
||||||
try {
|
try {
|
||||||
load_schema(value.result.at("schema").get<std::string>());
|
load_schema(path_from_utf8(value.result.at("schema").get<std::string>()));
|
||||||
// This signature represents the sources submitted with this job, not later
|
// This signature represents the sources submitted with this job, not later
|
||||||
// edits.
|
// edits.
|
||||||
if (value.result.contains("source_signature"))
|
if (value.result.contains("source_signature"))
|
||||||
@@ -245,10 +270,9 @@ void Session::poll() {
|
|||||||
if (value.state == "succeeded" && schema_valid)
|
if (value.state == "succeeded" && schema_valid)
|
||||||
try {
|
try {
|
||||||
const auto executable =
|
const auto executable =
|
||||||
value.result.value("player", (builds_.config().build_directory /
|
value.result.value("player", path_to_utf8(builds_.config().build_directory /
|
||||||
executable_name("faset_player"))
|
executable_name("faset_player")));
|
||||||
.string());
|
launch_player(pending_play_scene_, path_from_utf8(executable));
|
||||||
launch_player(pending_play_scene_, executable);
|
|
||||||
} catch (const std::exception& error) {
|
} catch (const std::exception& error) {
|
||||||
log(std::string("Play failed: ") + error.what());
|
log(std::string("Play failed: ") + error.what());
|
||||||
}
|
}
|
||||||
@@ -332,7 +356,8 @@ void Session::register_commands() {
|
|||||||
else {
|
else {
|
||||||
require(field.is_string() && !field.get<std::string>().empty(),
|
require(field.is_string() && !field.get<std::string>().empty(),
|
||||||
"project.start_scene", "Choose a saved scene inside the project");
|
"project.start_scene", "Choose a saved scene inside the project");
|
||||||
const auto file = project_path(config_.project_root, field.get<std::string>());
|
const auto file = project_path(config_.project_root,
|
||||||
|
path_from_utf8(field.get<std::string>()));
|
||||||
require(std::filesystem::is_regular_file(file), "project.start_scene",
|
require(std::filesystem::is_regular_file(file), "project.start_scene",
|
||||||
"Save the start scene before selecting it in Project settings");
|
"Save the start scene before selecting it in Project settings");
|
||||||
const auto scene = read_json(file);
|
const auto scene = read_json(file);
|
||||||
@@ -358,15 +383,25 @@ void Session::register_commands() {
|
|||||||
"a cancellable job ID; failure retains the last successful generation.",
|
"a cancellable job ID; failure retains the last successful generation.",
|
||||||
schema({{"path", text},
|
schema({{"path", text},
|
||||||
{"settings", {{"type", "object"}}},
|
{"settings", {{"type", "object"}}},
|
||||||
{"allow_removed_outputs", boolean}},
|
{"allow_removed_outputs", boolean},
|
||||||
|
{"expected_generation", text},
|
||||||
|
{"expected_active_generation", text}},
|
||||||
{"path"}),
|
{"path"}),
|
||||||
[&](const Json& args) {
|
[&](const Json& args) {
|
||||||
assets::ImportRequest request;
|
assets::ImportRequest request;
|
||||||
request.source = project_path(config_.project_root, args.at("path").get<std::string>());
|
request.source = project_path(config_.project_root,
|
||||||
|
path_from_utf8(args.at("path").get<std::string>()));
|
||||||
request.settings = args.value("settings", Json(nullptr));
|
request.settings = args.value("settings", Json(nullptr));
|
||||||
request.allow_removed_outputs = args.value("allow_removed_outputs", false);
|
request.allow_removed_outputs = args.value("allow_removed_outputs", false);
|
||||||
|
request.expected_generation = args.value("expected_generation", std::string());
|
||||||
|
request.expected_active_generation =
|
||||||
|
args.value("expected_active_generation", std::string());
|
||||||
auto task = std::make_shared<ImportTask>();
|
auto task = std::make_shared<ImportTask>();
|
||||||
task->id = "import-" + new_id();
|
task->id = "import-" + new_id();
|
||||||
|
task->request = {{"path", generic_path_to_utf8(std::filesystem::relative(
|
||||||
|
request.source, config_.project_root))},
|
||||||
|
{"settings", request.settings},
|
||||||
|
{"allow_removed_outputs", request.allow_removed_outputs}};
|
||||||
imports_[task->id] = task;
|
imports_[task->id] = task;
|
||||||
workers_.emplace_back([this, task, request] {
|
workers_.emplace_back([this, task, request] {
|
||||||
{
|
{
|
||||||
@@ -382,6 +417,8 @@ void Session::register_commands() {
|
|||||||
: "failed";
|
: "failed";
|
||||||
task->result = {{"asset_id", result.asset_id},
|
task->result = {{"asset_id", result.asset_id},
|
||||||
{"generation", result.generation},
|
{"generation", result.generation},
|
||||||
|
{"previous_generation", result.previous_generation},
|
||||||
|
{"removed_output_ids", result.removed_output_ids},
|
||||||
{"diagnostics", result.diagnostics},
|
{"diagnostics", result.diagnostics},
|
||||||
{"cache_hit", result.cache_hit},
|
{"cache_hit", result.cache_hit},
|
||||||
{"manifest", result.manifest}};
|
{"manifest", result.manifest}};
|
||||||
@@ -417,7 +454,8 @@ void Session::register_commands() {
|
|||||||
const auto signature = source_signature();
|
const auto signature = source_signature();
|
||||||
const auto id = builds_.start_export(
|
const auto id = builds_.start_export(
|
||||||
resolved_or_throw(commands_, args.at("document")),
|
resolved_or_throw(commands_, args.at("document")),
|
||||||
project_path(config_.project_root, args.at("output").get<std::string>()));
|
project_path(config_.project_root,
|
||||||
|
path_from_utf8(args.at("output").get<std::string>())));
|
||||||
submitted_sources_[id] = signature;
|
submitted_sources_[id] = signature;
|
||||||
return Json{{"job", id}};
|
return Json{{"job", id}};
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -300,6 +300,7 @@ render::Snapshot SceneView::build(const Json& scene, float aspect, CameraSetting
|
|||||||
out.light_direction = direction(model, {-0.5f, -1, -0.3f});
|
out.light_direction = direction(model, {-0.5f, -1, -0.3f});
|
||||||
if (auto fields = properties(entity, "sprite"); !fields.is_null()) {
|
if (auto fields = properties(entity, "sprite"); !fields.is_null()) {
|
||||||
render::Sprite sprite;
|
render::Sprite sprite;
|
||||||
|
sprite.layer = fields.value("layer", 0);
|
||||||
sprite.position = point(model, {0, 0, 0});
|
sprite.position = point(model, {0, 0, 0});
|
||||||
auto size = vec<2>(fields, "size", {1, 1});
|
auto size = vec<2>(fields, "size", {1, 1});
|
||||||
float sx = std::hypot(model[0], model[1]), sy = std::hypot(model[4], model[5]);
|
float sx = std::hypot(model[0], model[1]), sy = std::hypot(model[4], model[5]);
|
||||||
|
|||||||
@@ -6,8 +6,8 @@
|
|||||||
namespace faset::player {
|
namespace faset::player {
|
||||||
nlohmann::json readScene(const std::filesystem::path& path) {
|
nlohmann::json readScene(const std::filesystem::path& path) {
|
||||||
if (!std::filesystem::is_regular_file(path))
|
if (!std::filesystem::is_regular_file(path))
|
||||||
throw std::runtime_error("Scene file does not exist: " + path.string());
|
throw std::runtime_error("Scene file does not exist: " + faset::path_to_utf8(path));
|
||||||
if (std::filesystem::file_size(path) > 256 * 1024 * 1024)
|
if (std::filesystem::file_size(faset::native_io_path(path)) > 256 * 1024 * 1024)
|
||||||
throw std::runtime_error("Scene exceeds the 256 MiB reader limit");
|
throw std::runtime_error("Scene exceeds the 256 MiB reader limit");
|
||||||
const auto bytes = faset::read_text(path);
|
const auto bytes = faset::read_text(path);
|
||||||
if (bytes.size() >= 8 && std::memcmp(bytes.data(), "FASETSCN", 8) == 0) {
|
if (bytes.size() >= 8 && std::memcmp(bytes.data(), "FASETSCN", 8) == 0) {
|
||||||
|
|||||||
+155
-74
@@ -6,6 +6,7 @@
|
|||||||
#include <chrono>
|
#include <chrono>
|
||||||
#include <cmath>
|
#include <cmath>
|
||||||
#include <cstring>
|
#include <cstring>
|
||||||
|
#include <faset/core/io.hpp>
|
||||||
#include <faset/render/render_graph.hpp>
|
#include <faset/render/render_graph.hpp>
|
||||||
#include <faset/render/renderer.hpp>
|
#include <faset/render/renderer.hpp>
|
||||||
#include <fstream>
|
#include <fstream>
|
||||||
@@ -88,7 +89,7 @@ struct Renderer::Impl {
|
|||||||
VkDescriptorPool descriptor_pool{};
|
VkDescriptorPool descriptor_pool{};
|
||||||
VkSampler shadow_sampler{}, color_sampler{};
|
VkSampler shadow_sampler{}, color_sampler{};
|
||||||
VkPipelineLayout pipeline_layout{};
|
VkPipelineLayout pipeline_layout{};
|
||||||
VkPipeline pipeline{}, ui_pipeline{}, shadow_pipeline{};
|
VkPipeline pipeline{}, ui_pipeline{}, shadow_pipeline{}, sprite_pipeline{};
|
||||||
struct GpuTexture {
|
struct GpuTexture {
|
||||||
Image image;
|
Image image;
|
||||||
VkDescriptorSet descriptor{};
|
VkDescriptorSet descriptor{};
|
||||||
@@ -150,6 +151,8 @@ struct Renderer::Impl {
|
|||||||
vkDestroyPipeline(device, ui_pipeline, nullptr);
|
vkDestroyPipeline(device, ui_pipeline, nullptr);
|
||||||
if (shadow_pipeline)
|
if (shadow_pipeline)
|
||||||
vkDestroyPipeline(device, shadow_pipeline, nullptr);
|
vkDestroyPipeline(device, shadow_pipeline, nullptr);
|
||||||
|
if (sprite_pipeline)
|
||||||
|
vkDestroyPipeline(device, sprite_pipeline, nullptr);
|
||||||
if (pipeline_layout)
|
if (pipeline_layout)
|
||||||
vkDestroyPipelineLayout(device, pipeline_layout, nullptr);
|
vkDestroyPipelineLayout(device, pipeline_layout, nullptr);
|
||||||
if (descriptor_pool)
|
if (descriptor_pool)
|
||||||
@@ -207,7 +210,8 @@ struct Renderer::Impl {
|
|||||||
VkMemoryPropertyFlags properties, VkMemoryPropertyFlags preferred = 0) {
|
VkMemoryPropertyFlags properties, VkMemoryPropertyFlags preferred = 0) {
|
||||||
Buffer b{};
|
Buffer b{};
|
||||||
b.size = bytes;
|
b.size = bytes;
|
||||||
VkBufferCreateInfo info{VK_STRUCTURE_TYPE_BUFFER_CREATE_INFO};
|
VkBufferCreateInfo info{};
|
||||||
|
info.sType = VK_STRUCTURE_TYPE_BUFFER_CREATE_INFO;
|
||||||
info.size = bytes;
|
info.size = bytes;
|
||||||
info.usage = usage;
|
info.usage = usage;
|
||||||
info.sharingMode = VK_SHARING_MODE_EXCLUSIVE;
|
info.sharingMode = VK_SHARING_MODE_EXCLUSIVE;
|
||||||
@@ -215,7 +219,8 @@ struct Renderer::Impl {
|
|||||||
try {
|
try {
|
||||||
VkMemoryRequirements req{};
|
VkMemoryRequirements req{};
|
||||||
vkGetBufferMemoryRequirements(device, b.handle, &req);
|
vkGetBufferMemoryRequirements(device, b.handle, &req);
|
||||||
VkMemoryAllocateInfo alloc{VK_STRUCTURE_TYPE_MEMORY_ALLOCATE_INFO};
|
VkMemoryAllocateInfo alloc{};
|
||||||
|
alloc.sType = VK_STRUCTURE_TYPE_MEMORY_ALLOCATE_INFO;
|
||||||
alloc.allocationSize = req.size;
|
alloc.allocationSize = req.size;
|
||||||
alloc.memoryTypeIndex = memory_type(req.memoryTypeBits, properties, preferred);
|
alloc.memoryTypeIndex = memory_type(req.memoryTypeBits, properties, preferred);
|
||||||
check(vkAllocateMemory(device, &alloc, nullptr, &b.memory), "Allocate buffer memory");
|
check(vkAllocateMemory(device, &alloc, nullptr, &b.memory), "Allocate buffer memory");
|
||||||
@@ -230,7 +235,8 @@ struct Renderer::Impl {
|
|||||||
Image make_image(std::uint32_t w, std::uint32_t h, VkFormat format, VkImageUsageFlags usage,
|
Image make_image(std::uint32_t w, std::uint32_t h, VkFormat format, VkImageUsageFlags usage,
|
||||||
VkImageAspectFlags aspect) {
|
VkImageAspectFlags aspect) {
|
||||||
Image image{};
|
Image image{};
|
||||||
VkImageCreateInfo info{VK_STRUCTURE_TYPE_IMAGE_CREATE_INFO};
|
VkImageCreateInfo info{};
|
||||||
|
info.sType = VK_STRUCTURE_TYPE_IMAGE_CREATE_INFO;
|
||||||
info.imageType = VK_IMAGE_TYPE_2D;
|
info.imageType = VK_IMAGE_TYPE_2D;
|
||||||
info.format = format;
|
info.format = format;
|
||||||
info.extent = {w, h, 1};
|
info.extent = {w, h, 1};
|
||||||
@@ -244,7 +250,8 @@ struct Renderer::Impl {
|
|||||||
try {
|
try {
|
||||||
VkMemoryRequirements req{};
|
VkMemoryRequirements req{};
|
||||||
vkGetImageMemoryRequirements(device, image.handle, &req);
|
vkGetImageMemoryRequirements(device, image.handle, &req);
|
||||||
VkMemoryAllocateInfo alloc{VK_STRUCTURE_TYPE_MEMORY_ALLOCATE_INFO};
|
VkMemoryAllocateInfo alloc{};
|
||||||
|
alloc.sType = VK_STRUCTURE_TYPE_MEMORY_ALLOCATE_INFO;
|
||||||
alloc.allocationSize = req.size;
|
alloc.allocationSize = req.size;
|
||||||
alloc.memoryTypeIndex =
|
alloc.memoryTypeIndex =
|
||||||
memory_type(req.memoryTypeBits, VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT);
|
memory_type(req.memoryTypeBits, VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT);
|
||||||
@@ -252,7 +259,8 @@ struct Renderer::Impl {
|
|||||||
"Allocate image memory");
|
"Allocate image memory");
|
||||||
image.allocation_size = req.size;
|
image.allocation_size = req.size;
|
||||||
check(vkBindImageMemory(device, image.handle, image.memory, 0), "Bind image memory");
|
check(vkBindImageMemory(device, image.handle, image.memory, 0), "Bind image memory");
|
||||||
VkImageViewCreateInfo view{VK_STRUCTURE_TYPE_IMAGE_VIEW_CREATE_INFO};
|
VkImageViewCreateInfo view{};
|
||||||
|
view.sType = VK_STRUCTURE_TYPE_IMAGE_VIEW_CREATE_INFO;
|
||||||
view.image = image.handle;
|
view.image = image.handle;
|
||||||
view.viewType = VK_IMAGE_VIEW_TYPE_2D;
|
view.viewType = VK_IMAGE_VIEW_TYPE_2D;
|
||||||
view.format = format;
|
view.format = format;
|
||||||
@@ -267,7 +275,8 @@ struct Renderer::Impl {
|
|||||||
void transition(VkCommandBuffer cmd, VkImage image, VkImageLayout& before, VkImageLayout after,
|
void transition(VkCommandBuffer cmd, VkImage image, VkImageLayout& before, VkImageLayout after,
|
||||||
VkImageAspectFlags aspect) {
|
VkImageAspectFlags aspect) {
|
||||||
// Conservative dependencies make the first single-queue backend auditable.
|
// Conservative dependencies make the first single-queue backend auditable.
|
||||||
VkImageMemoryBarrier2 barrier{VK_STRUCTURE_TYPE_IMAGE_MEMORY_BARRIER_2};
|
VkImageMemoryBarrier2 barrier{};
|
||||||
|
barrier.sType = VK_STRUCTURE_TYPE_IMAGE_MEMORY_BARRIER_2;
|
||||||
barrier.srcStageMask = before == VK_IMAGE_LAYOUT_UNDEFINED
|
barrier.srcStageMask = before == VK_IMAGE_LAYOUT_UNDEFINED
|
||||||
? VK_PIPELINE_STAGE_2_NONE
|
? VK_PIPELINE_STAGE_2_NONE
|
||||||
: VK_PIPELINE_STAGE_2_ALL_COMMANDS_BIT;
|
: VK_PIPELINE_STAGE_2_ALL_COMMANDS_BIT;
|
||||||
@@ -280,7 +289,8 @@ struct Renderer::Impl {
|
|||||||
barrier.srcQueueFamilyIndex = barrier.dstQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED;
|
barrier.srcQueueFamilyIndex = barrier.dstQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED;
|
||||||
barrier.image = image;
|
barrier.image = image;
|
||||||
barrier.subresourceRange = {aspect, 0, 1, 0, 1};
|
barrier.subresourceRange = {aspect, 0, 1, 0, 1};
|
||||||
VkDependencyInfo dependency{VK_STRUCTURE_TYPE_DEPENDENCY_INFO};
|
VkDependencyInfo dependency{};
|
||||||
|
dependency.sType = VK_STRUCTURE_TYPE_DEPENDENCY_INFO;
|
||||||
dependency.imageMemoryBarrierCount = 1;
|
dependency.imageMemoryBarrierCount = 1;
|
||||||
dependency.pImageMemoryBarriers = &barrier;
|
dependency.pImageMemoryBarriers = &barrier;
|
||||||
vkCmdPipelineBarrier2(cmd, &dependency);
|
vkCmdPipelineBarrier2(cmd, &dependency);
|
||||||
@@ -292,20 +302,23 @@ struct Renderer::Impl {
|
|||||||
}
|
}
|
||||||
void begin() {
|
void begin() {
|
||||||
check(vkResetCommandBuffer(command, 0), "Reset command buffer");
|
check(vkResetCommandBuffer(command, 0), "Reset command buffer");
|
||||||
VkCommandBufferBeginInfo info{VK_STRUCTURE_TYPE_COMMAND_BUFFER_BEGIN_INFO};
|
VkCommandBufferBeginInfo info{};
|
||||||
|
info.sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_BEGIN_INFO;
|
||||||
info.flags = VK_COMMAND_BUFFER_USAGE_ONE_TIME_SUBMIT_BIT;
|
info.flags = VK_COMMAND_BUFFER_USAGE_ONE_TIME_SUBMIT_BIT;
|
||||||
check(vkBeginCommandBuffer(command, &info), "Begin command buffer");
|
check(vkBeginCommandBuffer(command, &info), "Begin command buffer");
|
||||||
}
|
}
|
||||||
void submit(bool present = false) {
|
void submit(bool present = false) {
|
||||||
check(vkEndCommandBuffer(command), "End command buffer");
|
check(vkEndCommandBuffer(command), "End command buffer");
|
||||||
check(vkResetFences(device, 1, &fence), "Reset fence");
|
check(vkResetFences(device, 1, &fence), "Reset fence");
|
||||||
VkCommandBufferSubmitInfo cmd{VK_STRUCTURE_TYPE_COMMAND_BUFFER_SUBMIT_INFO};
|
VkCommandBufferSubmitInfo cmd{};
|
||||||
|
cmd.sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_SUBMIT_INFO;
|
||||||
cmd.commandBuffer = command;
|
cmd.commandBuffer = command;
|
||||||
VkSubmitInfo2 info{VK_STRUCTURE_TYPE_SUBMIT_INFO_2};
|
VkSubmitInfo2 info{};
|
||||||
|
info.sType = VK_STRUCTURE_TYPE_SUBMIT_INFO_2;
|
||||||
info.commandBufferInfoCount = 1;
|
info.commandBufferInfoCount = 1;
|
||||||
info.pCommandBufferInfos = &cmd;
|
info.pCommandBufferInfos = &cmd;
|
||||||
VkSemaphoreSubmitInfo wait{VK_STRUCTURE_TYPE_SEMAPHORE_SUBMIT_INFO},
|
VkSemaphoreSubmitInfo wait{}, signal{};
|
||||||
signal{VK_STRUCTURE_TYPE_SEMAPHORE_SUBMIT_INFO};
|
wait.sType = signal.sType = VK_STRUCTURE_TYPE_SEMAPHORE_SUBMIT_INFO;
|
||||||
if (present) {
|
if (present) {
|
||||||
wait.semaphore = acquired;
|
wait.semaphore = acquired;
|
||||||
wait.stageMask = VK_PIPELINE_STAGE_2_TRANSFER_BIT;
|
wait.stageMask = VK_PIPELINE_STAGE_2_TRANSFER_BIT;
|
||||||
@@ -354,11 +367,12 @@ struct Renderer::Impl {
|
|||||||
std::cerr << "[Faset] Vulkan validation layer not installed; diagnostics disabled.\n";
|
std::cerr << "[Faset] Vulkan validation layer not installed; diagnostics disabled.\n";
|
||||||
if (validation)
|
if (validation)
|
||||||
extensions.push_back(VK_EXT_DEBUG_UTILS_EXTENSION_NAME);
|
extensions.push_back(VK_EXT_DEBUG_UTILS_EXTENSION_NAME);
|
||||||
VkApplicationInfo app{VK_STRUCTURE_TYPE_APPLICATION_INFO};
|
VkApplicationInfo app{};
|
||||||
|
app.sType = VK_STRUCTURE_TYPE_APPLICATION_INFO;
|
||||||
app.pApplicationName = "Faset Engine";
|
app.pApplicationName = "Faset Engine";
|
||||||
app.apiVersion = VK_API_VERSION_1_3;
|
app.apiVersion = VK_API_VERSION_1_3;
|
||||||
VkDebugUtilsMessengerCreateInfoEXT debug_info{
|
VkDebugUtilsMessengerCreateInfoEXT debug_info{};
|
||||||
VK_STRUCTURE_TYPE_DEBUG_UTILS_MESSENGER_CREATE_INFO_EXT};
|
debug_info.sType = VK_STRUCTURE_TYPE_DEBUG_UTILS_MESSENGER_CREATE_INFO_EXT;
|
||||||
debug_info.messageSeverity = VK_DEBUG_UTILS_MESSAGE_SEVERITY_WARNING_BIT_EXT |
|
debug_info.messageSeverity = VK_DEBUG_UTILS_MESSAGE_SEVERITY_WARNING_BIT_EXT |
|
||||||
VK_DEBUG_UTILS_MESSAGE_SEVERITY_ERROR_BIT_EXT;
|
VK_DEBUG_UTILS_MESSAGE_SEVERITY_ERROR_BIT_EXT;
|
||||||
debug_info.messageType = VK_DEBUG_UTILS_MESSAGE_TYPE_VALIDATION_BIT_EXT |
|
debug_info.messageType = VK_DEBUG_UTILS_MESSAGE_TYPE_VALIDATION_BIT_EXT |
|
||||||
@@ -367,7 +381,8 @@ struct Renderer::Impl {
|
|||||||
debug_info.pfnUserCallback = debug;
|
debug_info.pfnUserCallback = debug;
|
||||||
debug_info.pUserData = this;
|
debug_info.pUserData = this;
|
||||||
const char* validation_name = "VK_LAYER_KHRONOS_validation";
|
const char* validation_name = "VK_LAYER_KHRONOS_validation";
|
||||||
VkInstanceCreateInfo info{VK_STRUCTURE_TYPE_INSTANCE_CREATE_INFO};
|
VkInstanceCreateInfo info{};
|
||||||
|
info.sType = VK_STRUCTURE_TYPE_INSTANCE_CREATE_INFO;
|
||||||
info.pApplicationInfo = &app;
|
info.pApplicationInfo = &app;
|
||||||
info.enabledExtensionCount = static_cast<std::uint32_t>(extensions.size());
|
info.enabledExtensionCount = static_cast<std::uint32_t>(extensions.size());
|
||||||
info.ppEnabledExtensionNames = extensions.data();
|
info.ppEnabledExtensionNames = extensions.data();
|
||||||
@@ -395,9 +410,10 @@ struct Renderer::Impl {
|
|||||||
vkGetPhysicalDeviceProperties(gpu, &properties);
|
vkGetPhysicalDeviceProperties(gpu, &properties);
|
||||||
if (properties.apiVersion < VK_API_VERSION_1_3)
|
if (properties.apiVersion < VK_API_VERSION_1_3)
|
||||||
continue;
|
continue;
|
||||||
VkPhysicalDeviceVulkan13Features f13{
|
VkPhysicalDeviceVulkan13Features f13{};
|
||||||
VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_VULKAN_1_3_FEATURES};
|
f13.sType = VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_VULKAN_1_3_FEATURES;
|
||||||
VkPhysicalDeviceFeatures2 features{VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_FEATURES_2};
|
VkPhysicalDeviceFeatures2 features{};
|
||||||
|
features.sType = VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_FEATURES_2;
|
||||||
features.pNext = &f13;
|
features.pNext = &f13;
|
||||||
vkGetPhysicalDeviceFeatures2(gpu, &features);
|
vkGetPhysicalDeviceFeatures2(gpu, &features);
|
||||||
if (!f13.synchronization2 || !f13.dynamicRendering)
|
if (!f13.synchronization2 || !f13.dynamicRendering)
|
||||||
@@ -436,14 +452,17 @@ struct Renderer::Impl {
|
|||||||
throw std::runtime_error("No Vulkan 1.3 device supports dynamic rendering, "
|
throw std::runtime_error("No Vulkan 1.3 device supports dynamic rendering, "
|
||||||
"synchronization2 and required color/depth formats");
|
"synchronization2 and required color/depth formats");
|
||||||
float priority = 1;
|
float priority = 1;
|
||||||
VkDeviceQueueCreateInfo qi{VK_STRUCTURE_TYPE_DEVICE_QUEUE_CREATE_INFO};
|
VkDeviceQueueCreateInfo qi{};
|
||||||
|
qi.sType = VK_STRUCTURE_TYPE_DEVICE_QUEUE_CREATE_INFO;
|
||||||
qi.queueFamilyIndex = queue_family;
|
qi.queueFamilyIndex = queue_family;
|
||||||
qi.queueCount = 1;
|
qi.queueCount = 1;
|
||||||
qi.pQueuePriorities = &priority;
|
qi.pQueuePriorities = &priority;
|
||||||
VkPhysicalDeviceVulkan13Features f13{VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_VULKAN_1_3_FEATURES};
|
VkPhysicalDeviceVulkan13Features f13{};
|
||||||
|
f13.sType = VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_VULKAN_1_3_FEATURES;
|
||||||
f13.synchronization2 = VK_TRUE;
|
f13.synchronization2 = VK_TRUE;
|
||||||
f13.dynamicRendering = VK_TRUE;
|
f13.dynamicRendering = VK_TRUE;
|
||||||
VkDeviceCreateInfo di{VK_STRUCTURE_TYPE_DEVICE_CREATE_INFO};
|
VkDeviceCreateInfo di{};
|
||||||
|
di.sType = VK_STRUCTURE_TYPE_DEVICE_CREATE_INFO;
|
||||||
di.pNext = &f13;
|
di.pNext = &f13;
|
||||||
di.queueCreateInfoCount = 1;
|
di.queueCreateInfoCount = 1;
|
||||||
di.pQueueCreateInfos = &qi;
|
di.pQueueCreateInfos = &qi;
|
||||||
@@ -454,23 +473,28 @@ struct Renderer::Impl {
|
|||||||
}
|
}
|
||||||
check(vkCreateDevice(physical, &di, nullptr, &device), "Create Vulkan device");
|
check(vkCreateDevice(physical, &di, nullptr, &device), "Create Vulkan device");
|
||||||
vkGetDeviceQueue(device, queue_family, 0, &queue);
|
vkGetDeviceQueue(device, queue_family, 0, &queue);
|
||||||
VkCommandPoolCreateInfo pi{VK_STRUCTURE_TYPE_COMMAND_POOL_CREATE_INFO};
|
VkCommandPoolCreateInfo pi{};
|
||||||
|
pi.sType = VK_STRUCTURE_TYPE_COMMAND_POOL_CREATE_INFO;
|
||||||
pi.queueFamilyIndex = queue_family;
|
pi.queueFamilyIndex = queue_family;
|
||||||
pi.flags = VK_COMMAND_POOL_CREATE_RESET_COMMAND_BUFFER_BIT;
|
pi.flags = VK_COMMAND_POOL_CREATE_RESET_COMMAND_BUFFER_BIT;
|
||||||
check(vkCreateCommandPool(device, &pi, nullptr, &pool), "Create command pool");
|
check(vkCreateCommandPool(device, &pi, nullptr, &pool), "Create command pool");
|
||||||
VkCommandBufferAllocateInfo ai{VK_STRUCTURE_TYPE_COMMAND_BUFFER_ALLOCATE_INFO};
|
VkCommandBufferAllocateInfo ai{};
|
||||||
|
ai.sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_ALLOCATE_INFO;
|
||||||
ai.commandPool = pool;
|
ai.commandPool = pool;
|
||||||
ai.level = VK_COMMAND_BUFFER_LEVEL_PRIMARY;
|
ai.level = VK_COMMAND_BUFFER_LEVEL_PRIMARY;
|
||||||
ai.commandBufferCount = 1;
|
ai.commandBufferCount = 1;
|
||||||
check(vkAllocateCommandBuffers(device, &ai, &command), "Allocate command buffer");
|
check(vkAllocateCommandBuffers(device, &ai, &command), "Allocate command buffer");
|
||||||
VkFenceCreateInfo fi{VK_STRUCTURE_TYPE_FENCE_CREATE_INFO};
|
VkFenceCreateInfo fi{};
|
||||||
|
fi.sType = VK_STRUCTURE_TYPE_FENCE_CREATE_INFO;
|
||||||
fi.flags = VK_FENCE_CREATE_SIGNALED_BIT;
|
fi.flags = VK_FENCE_CREATE_SIGNALED_BIT;
|
||||||
check(vkCreateFence(device, &fi, nullptr, &fence), "Create frame fence");
|
check(vkCreateFence(device, &fi, nullptr, &fence), "Create frame fence");
|
||||||
VkSemaphoreCreateInfo si{VK_STRUCTURE_TYPE_SEMAPHORE_CREATE_INFO};
|
VkSemaphoreCreateInfo si{};
|
||||||
|
si.sType = VK_STRUCTURE_TYPE_SEMAPHORE_CREATE_INFO;
|
||||||
check(vkCreateSemaphore(device, &si, nullptr, &acquired), "Create acquire semaphore");
|
check(vkCreateSemaphore(device, &si, nullptr, &acquired), "Create acquire semaphore");
|
||||||
check(vkCreateSemaphore(device, &si, nullptr, &present_ready), "Create present semaphore");
|
check(vkCreateSemaphore(device, &si, nullptr, &present_ready), "Create present semaphore");
|
||||||
if (timestamp_bits) {
|
if (timestamp_bits) {
|
||||||
VkQueryPoolCreateInfo query{VK_STRUCTURE_TYPE_QUERY_POOL_CREATE_INFO};
|
VkQueryPoolCreateInfo query{};
|
||||||
|
query.sType = VK_STRUCTURE_TYPE_QUERY_POOL_CREATE_INFO;
|
||||||
query.queryType = VK_QUERY_TYPE_TIMESTAMP;
|
query.queryType = VK_QUERY_TYPE_TIMESTAMP;
|
||||||
query.queryCount = 2;
|
query.queryCount = 2;
|
||||||
check(vkCreateQueryPool(device, &query, nullptr, ×tamp_pool),
|
check(vkCreateQueryPool(device, &query, nullptr, ×tamp_pool),
|
||||||
@@ -545,7 +569,8 @@ struct Renderer::Impl {
|
|||||||
count = caps.minImageCount + 1;
|
count = caps.minImageCount + 1;
|
||||||
if (caps.maxImageCount)
|
if (caps.maxImageCount)
|
||||||
count = std::min(count, caps.maxImageCount);
|
count = std::min(count, caps.maxImageCount);
|
||||||
VkSwapchainCreateInfoKHR info{VK_STRUCTURE_TYPE_SWAPCHAIN_CREATE_INFO_KHR};
|
VkSwapchainCreateInfoKHR info{};
|
||||||
|
info.sType = VK_STRUCTURE_TYPE_SWAPCHAIN_CREATE_INFO_KHR;
|
||||||
info.surface = surface;
|
info.surface = surface;
|
||||||
info.minImageCount = count;
|
info.minImageCount = count;
|
||||||
info.imageFormat = chosen.format;
|
info.imageFormat = chosen.format;
|
||||||
@@ -595,21 +620,24 @@ struct Renderer::Impl {
|
|||||||
bindings[i].descriptorCount = 1;
|
bindings[i].descriptorCount = 1;
|
||||||
bindings[i].stageFlags = VK_SHADER_STAGE_FRAGMENT_BIT;
|
bindings[i].stageFlags = VK_SHADER_STAGE_FRAGMENT_BIT;
|
||||||
}
|
}
|
||||||
VkDescriptorSetLayoutCreateInfo li{VK_STRUCTURE_TYPE_DESCRIPTOR_SET_LAYOUT_CREATE_INFO};
|
VkDescriptorSetLayoutCreateInfo li{};
|
||||||
|
li.sType = VK_STRUCTURE_TYPE_DESCRIPTOR_SET_LAYOUT_CREATE_INFO;
|
||||||
li.bindingCount = 4;
|
li.bindingCount = 4;
|
||||||
li.pBindings = bindings.data();
|
li.pBindings = bindings.data();
|
||||||
check(vkCreateDescriptorSetLayout(device, &li, nullptr, &descriptor_layout),
|
check(vkCreateDescriptorSetLayout(device, &li, nullptr, &descriptor_layout),
|
||||||
"Create descriptor layout");
|
"Create descriptor layout");
|
||||||
VkDescriptorPoolSize sizes[] = {{VK_DESCRIPTOR_TYPE_SAMPLED_IMAGE, 2048},
|
VkDescriptorPoolSize sizes[] = {{VK_DESCRIPTOR_TYPE_SAMPLED_IMAGE, 2048},
|
||||||
{VK_DESCRIPTOR_TYPE_SAMPLER, 2048}};
|
{VK_DESCRIPTOR_TYPE_SAMPLER, 2048}};
|
||||||
VkDescriptorPoolCreateInfo pi{VK_STRUCTURE_TYPE_DESCRIPTOR_POOL_CREATE_INFO};
|
VkDescriptorPoolCreateInfo pi{};
|
||||||
|
pi.sType = VK_STRUCTURE_TYPE_DESCRIPTOR_POOL_CREATE_INFO;
|
||||||
pi.flags = VK_DESCRIPTOR_POOL_CREATE_FREE_DESCRIPTOR_SET_BIT;
|
pi.flags = VK_DESCRIPTOR_POOL_CREATE_FREE_DESCRIPTOR_SET_BIT;
|
||||||
pi.maxSets = 1024;
|
pi.maxSets = 1024;
|
||||||
pi.poolSizeCount = 2;
|
pi.poolSizeCount = 2;
|
||||||
pi.pPoolSizes = sizes;
|
pi.pPoolSizes = sizes;
|
||||||
check(vkCreateDescriptorPool(device, &pi, nullptr, &descriptor_pool),
|
check(vkCreateDescriptorPool(device, &pi, nullptr, &descriptor_pool),
|
||||||
"Create descriptor pool");
|
"Create descriptor pool");
|
||||||
VkSamplerCreateInfo si{VK_STRUCTURE_TYPE_SAMPLER_CREATE_INFO};
|
VkSamplerCreateInfo si{};
|
||||||
|
si.sType = VK_STRUCTURE_TYPE_SAMPLER_CREATE_INFO;
|
||||||
si.magFilter = si.minFilter = VK_FILTER_NEAREST;
|
si.magFilter = si.minFilter = VK_FILTER_NEAREST;
|
||||||
si.mipmapMode = VK_SAMPLER_MIPMAP_MODE_NEAREST;
|
si.mipmapMode = VK_SAMPLER_MIPMAP_MODE_NEAREST;
|
||||||
si.addressModeU = si.addressModeV = si.addressModeW = VK_SAMPLER_ADDRESS_MODE_CLAMP_TO_EDGE;
|
si.addressModeU = si.addressModeV = si.addressModeW = VK_SAMPLER_ADDRESS_MODE_CLAMP_TO_EDGE;
|
||||||
@@ -658,7 +686,8 @@ struct Renderer::Impl {
|
|||||||
VK_IMAGE_ASPECT_COLOR_BIT);
|
VK_IMAGE_ASPECT_COLOR_BIT);
|
||||||
submit();
|
submit();
|
||||||
destroy(staging);
|
destroy(staging);
|
||||||
VkDescriptorSetAllocateInfo ai{VK_STRUCTURE_TYPE_DESCRIPTOR_SET_ALLOCATE_INFO};
|
VkDescriptorSetAllocateInfo ai{};
|
||||||
|
ai.sType = VK_STRUCTURE_TYPE_DESCRIPTOR_SET_ALLOCATE_INFO;
|
||||||
ai.descriptorPool = descriptor_pool;
|
ai.descriptorPool = descriptor_pool;
|
||||||
ai.descriptorSetCount = 1;
|
ai.descriptorSetCount = 1;
|
||||||
ai.pSetLayouts = &descriptor_layout;
|
ai.pSetLayouts = &descriptor_layout;
|
||||||
@@ -671,7 +700,7 @@ struct Renderer::Impl {
|
|||||||
{color_sampler, VK_NULL_HANDLE, VK_IMAGE_LAYOUT_UNDEFINED}};
|
{color_sampler, VK_NULL_HANDLE, VK_IMAGE_LAYOUT_UNDEFINED}};
|
||||||
std::array<VkWriteDescriptorSet, 4> writes{};
|
std::array<VkWriteDescriptorSet, 4> writes{};
|
||||||
for (std::uint32_t i = 0; i < 4; ++i) {
|
for (std::uint32_t i = 0; i < 4; ++i) {
|
||||||
writes[i] = {VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET};
|
writes[i].sType = VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET;
|
||||||
writes[i].dstSet = texture.descriptor;
|
writes[i].dstSet = texture.descriptor;
|
||||||
writes[i].dstBinding = i;
|
writes[i].dstBinding = i;
|
||||||
writes[i].descriptorCount = 1;
|
writes[i].descriptorCount = 1;
|
||||||
@@ -700,16 +729,17 @@ struct Renderer::Impl {
|
|||||||
std::vector<std::filesystem::path> roots;
|
std::vector<std::filesystem::path> roots;
|
||||||
const char* base = SDL_GetBasePath();
|
const char* base = SDL_GetBasePath();
|
||||||
if (base)
|
if (base)
|
||||||
roots.emplace_back(std::filesystem::path(base) / "shaders");
|
roots.emplace_back(faset::path_from_utf8(base) / "shaders");
|
||||||
roots.emplace_back(std::filesystem::current_path() / "shaders");
|
roots.emplace_back(std::filesystem::current_path() / "shaders");
|
||||||
roots.emplace_back(FASET_SHADER_DIRECTORY);
|
roots.emplace_back(faset::path_from_utf8(FASET_SHADER_DIRECTORY));
|
||||||
for (const auto& root : roots)
|
for (const auto& root : roots)
|
||||||
if (std::filesystem::is_regular_file(root / "vertexMain.spv"))
|
if (std::filesystem::is_regular_file(faset::native_io_path(root / "vertexMain.spv")))
|
||||||
return root;
|
return root;
|
||||||
throw std::runtime_error("Compiled Slang shader bundle is missing");
|
throw std::runtime_error("Compiled Slang shader bundle is missing");
|
||||||
}
|
}
|
||||||
VkShaderModule shader(const detail::ShaderCode& code) {
|
VkShaderModule shader(const detail::ShaderCode& code) {
|
||||||
VkShaderModuleCreateInfo ci{VK_STRUCTURE_TYPE_SHADER_MODULE_CREATE_INFO};
|
VkShaderModuleCreateInfo ci{};
|
||||||
|
ci.sType = VK_STRUCTURE_TYPE_SHADER_MODULE_CREATE_INFO;
|
||||||
ci.codeSize = code.words.size() * sizeof(std::uint32_t);
|
ci.codeSize = code.words.size() * sizeof(std::uint32_t);
|
||||||
ci.pCode = code.words.data();
|
ci.pCode = code.words.data();
|
||||||
VkShaderModule result{};
|
VkShaderModule result{};
|
||||||
@@ -724,7 +754,8 @@ struct Renderer::Impl {
|
|||||||
"Shader layout changed; the current pipeline was preserved");
|
"Shader layout changed; the current pipeline was preserved");
|
||||||
VkPushConstantRange push{VK_SHADER_STAGE_VERTEX_BIT | VK_SHADER_STAGE_FRAGMENT_BIT, 0,
|
VkPushConstantRange push{VK_SHADER_STAGE_VERTEX_BIT | VK_SHADER_STAGE_FRAGMENT_BIT, 0,
|
||||||
sizeof(Push)};
|
sizeof(Push)};
|
||||||
VkPipelineLayoutCreateInfo li{VK_STRUCTURE_TYPE_PIPELINE_LAYOUT_CREATE_INFO};
|
VkPipelineLayoutCreateInfo li{};
|
||||||
|
li.sType = VK_STRUCTURE_TYPE_PIPELINE_LAYOUT_CREATE_INFO;
|
||||||
li.setLayoutCount = 1;
|
li.setLayoutCount = 1;
|
||||||
li.pSetLayouts = &descriptor_layout;
|
li.pSetLayouts = &descriptor_layout;
|
||||||
li.pushConstantRangeCount = 1;
|
li.pushConstantRangeCount = 1;
|
||||||
@@ -736,14 +767,14 @@ struct Renderer::Impl {
|
|||||||
vertex = shader(shaders[0]);
|
vertex = shader(shaders[0]);
|
||||||
fragment = shader(shaders[1]);
|
fragment = shader(shaders[1]);
|
||||||
shadow_vertex = shader(shaders[2]);
|
shadow_vertex = shader(shaders[2]);
|
||||||
for (int mode = 0; mode < 3; ++mode) {
|
for (int mode = 0; mode < 4; ++mode) {
|
||||||
bool shadow_pass = mode == 2, ui = mode == 1;
|
bool shadow_pass = mode == 2, ui = mode == 1, sprite = mode == 3;
|
||||||
VkPipelineShaderStageCreateInfo stages[2]{};
|
VkPipelineShaderStageCreateInfo stages[2]{};
|
||||||
stages[0] = {VK_STRUCTURE_TYPE_PIPELINE_SHADER_STAGE_CREATE_INFO};
|
stages[0].sType = VK_STRUCTURE_TYPE_PIPELINE_SHADER_STAGE_CREATE_INFO;
|
||||||
stages[0].stage = VK_SHADER_STAGE_VERTEX_BIT;
|
stages[0].stage = VK_SHADER_STAGE_VERTEX_BIT;
|
||||||
stages[0].module = shadow_pass ? shadow_vertex : vertex;
|
stages[0].module = shadow_pass ? shadow_vertex : vertex;
|
||||||
stages[0].pName = "main";
|
stages[0].pName = "main";
|
||||||
stages[1] = {VK_STRUCTURE_TYPE_PIPELINE_SHADER_STAGE_CREATE_INFO};
|
stages[1].sType = VK_STRUCTURE_TYPE_PIPELINE_SHADER_STAGE_CREATE_INFO;
|
||||||
stages[1].stage = VK_SHADER_STAGE_FRAGMENT_BIT;
|
stages[1].stage = VK_SHADER_STAGE_FRAGMENT_BIT;
|
||||||
stages[1].module = fragment;
|
stages[1].module = fragment;
|
||||||
stages[1].pName = "main";
|
stages[1].pName = "main";
|
||||||
@@ -756,20 +787,20 @@ struct Renderer::Impl {
|
|||||||
{3, 0, VK_FORMAT_R32G32B32A32_SFLOAT, offsetof(GpuVertex, color)},
|
{3, 0, VK_FORMAT_R32G32B32A32_SFLOAT, offsetof(GpuVertex, color)},
|
||||||
{4, 0, VK_FORMAT_R32G32_SFLOAT, offsetof(GpuVertex, material)},
|
{4, 0, VK_FORMAT_R32G32_SFLOAT, offsetof(GpuVertex, material)},
|
||||||
{5, 0, VK_FORMAT_R32G32_SFLOAT, offsetof(GpuVertex, uv)}};
|
{5, 0, VK_FORMAT_R32G32_SFLOAT, offsetof(GpuVertex, uv)}};
|
||||||
VkPipelineVertexInputStateCreateInfo vi{
|
VkPipelineVertexInputStateCreateInfo vi{};
|
||||||
VK_STRUCTURE_TYPE_PIPELINE_VERTEX_INPUT_STATE_CREATE_INFO};
|
vi.sType = VK_STRUCTURE_TYPE_PIPELINE_VERTEX_INPUT_STATE_CREATE_INFO;
|
||||||
vi.vertexBindingDescriptionCount = 1;
|
vi.vertexBindingDescriptionCount = 1;
|
||||||
vi.pVertexBindingDescriptions = &binding;
|
vi.pVertexBindingDescriptions = &binding;
|
||||||
vi.vertexAttributeDescriptionCount = shadow_pass ? 1 : 6;
|
vi.vertexAttributeDescriptionCount = shadow_pass ? 1 : 6;
|
||||||
vi.pVertexAttributeDescriptions = shadow_pass ? attrs + 1 : attrs;
|
vi.pVertexAttributeDescriptions = shadow_pass ? attrs + 1 : attrs;
|
||||||
VkPipelineInputAssemblyStateCreateInfo ia{
|
VkPipelineInputAssemblyStateCreateInfo ia{};
|
||||||
VK_STRUCTURE_TYPE_PIPELINE_INPUT_ASSEMBLY_STATE_CREATE_INFO};
|
ia.sType = VK_STRUCTURE_TYPE_PIPELINE_INPUT_ASSEMBLY_STATE_CREATE_INFO;
|
||||||
ia.topology = VK_PRIMITIVE_TOPOLOGY_TRIANGLE_LIST;
|
ia.topology = VK_PRIMITIVE_TOPOLOGY_TRIANGLE_LIST;
|
||||||
VkPipelineViewportStateCreateInfo vp{
|
VkPipelineViewportStateCreateInfo vp{};
|
||||||
VK_STRUCTURE_TYPE_PIPELINE_VIEWPORT_STATE_CREATE_INFO};
|
vp.sType = VK_STRUCTURE_TYPE_PIPELINE_VIEWPORT_STATE_CREATE_INFO;
|
||||||
vp.viewportCount = vp.scissorCount = 1;
|
vp.viewportCount = vp.scissorCount = 1;
|
||||||
VkPipelineRasterizationStateCreateInfo rs{
|
VkPipelineRasterizationStateCreateInfo rs{};
|
||||||
VK_STRUCTURE_TYPE_PIPELINE_RASTERIZATION_STATE_CREATE_INFO};
|
rs.sType = VK_STRUCTURE_TYPE_PIPELINE_RASTERIZATION_STATE_CREATE_INFO;
|
||||||
rs.polygonMode = VK_POLYGON_MODE_FILL;
|
rs.polygonMode = VK_POLYGON_MODE_FILL;
|
||||||
rs.cullMode = VK_CULL_MODE_NONE;
|
rs.cullMode = VK_CULL_MODE_NONE;
|
||||||
rs.frontFace = VK_FRONT_FACE_COUNTER_CLOCKWISE;
|
rs.frontFace = VK_FRONT_FACE_COUNTER_CLOCKWISE;
|
||||||
@@ -777,13 +808,13 @@ struct Renderer::Impl {
|
|||||||
rs.depthBiasEnable = shadow_pass;
|
rs.depthBiasEnable = shadow_pass;
|
||||||
rs.depthBiasConstantFactor = 1.25f;
|
rs.depthBiasConstantFactor = 1.25f;
|
||||||
rs.depthBiasSlopeFactor = 1.75f;
|
rs.depthBiasSlopeFactor = 1.75f;
|
||||||
VkPipelineMultisampleStateCreateInfo ms{
|
VkPipelineMultisampleStateCreateInfo ms{};
|
||||||
VK_STRUCTURE_TYPE_PIPELINE_MULTISAMPLE_STATE_CREATE_INFO};
|
ms.sType = VK_STRUCTURE_TYPE_PIPELINE_MULTISAMPLE_STATE_CREATE_INFO;
|
||||||
ms.rasterizationSamples = VK_SAMPLE_COUNT_1_BIT;
|
ms.rasterizationSamples = VK_SAMPLE_COUNT_1_BIT;
|
||||||
VkPipelineDepthStencilStateCreateInfo ds{
|
VkPipelineDepthStencilStateCreateInfo ds{};
|
||||||
VK_STRUCTURE_TYPE_PIPELINE_DEPTH_STENCIL_STATE_CREATE_INFO};
|
ds.sType = VK_STRUCTURE_TYPE_PIPELINE_DEPTH_STENCIL_STATE_CREATE_INFO;
|
||||||
ds.depthTestEnable = !ui;
|
ds.depthTestEnable = !ui;
|
||||||
ds.depthWriteEnable = !ui;
|
ds.depthWriteEnable = !ui && !sprite;
|
||||||
ds.depthCompareOp = VK_COMPARE_OP_LESS_OR_EQUAL;
|
ds.depthCompareOp = VK_COMPARE_OP_LESS_OR_EQUAL;
|
||||||
VkPipelineColorBlendAttachmentState blend{};
|
VkPipelineColorBlendAttachmentState blend{};
|
||||||
blend.colorWriteMask = 15;
|
blend.colorWriteMask = 15;
|
||||||
@@ -794,22 +825,23 @@ struct Renderer::Impl {
|
|||||||
blend.srcAlphaBlendFactor = VK_BLEND_FACTOR_ONE;
|
blend.srcAlphaBlendFactor = VK_BLEND_FACTOR_ONE;
|
||||||
blend.dstAlphaBlendFactor = VK_BLEND_FACTOR_ONE_MINUS_SRC_ALPHA;
|
blend.dstAlphaBlendFactor = VK_BLEND_FACTOR_ONE_MINUS_SRC_ALPHA;
|
||||||
blend.alphaBlendOp = VK_BLEND_OP_ADD;
|
blend.alphaBlendOp = VK_BLEND_OP_ADD;
|
||||||
VkPipelineColorBlendStateCreateInfo cb{
|
VkPipelineColorBlendStateCreateInfo cb{};
|
||||||
VK_STRUCTURE_TYPE_PIPELINE_COLOR_BLEND_STATE_CREATE_INFO};
|
cb.sType = VK_STRUCTURE_TYPE_PIPELINE_COLOR_BLEND_STATE_CREATE_INFO;
|
||||||
cb.attachmentCount = shadow_pass ? 0 : 1;
|
cb.attachmentCount = shadow_pass ? 0 : 1;
|
||||||
cb.pAttachments = &blend;
|
cb.pAttachments = &blend;
|
||||||
VkDynamicState states[] = {VK_DYNAMIC_STATE_VIEWPORT, VK_DYNAMIC_STATE_SCISSOR};
|
VkDynamicState states[] = {VK_DYNAMIC_STATE_VIEWPORT, VK_DYNAMIC_STATE_SCISSOR};
|
||||||
VkPipelineDynamicStateCreateInfo dynamic{
|
VkPipelineDynamicStateCreateInfo dynamic{};
|
||||||
VK_STRUCTURE_TYPE_PIPELINE_DYNAMIC_STATE_CREATE_INFO};
|
dynamic.sType = VK_STRUCTURE_TYPE_PIPELINE_DYNAMIC_STATE_CREATE_INFO;
|
||||||
dynamic.dynamicStateCount = 2;
|
dynamic.dynamicStateCount = 2;
|
||||||
dynamic.pDynamicStates = states;
|
dynamic.pDynamicStates = states;
|
||||||
VkFormat format = VK_FORMAT_R8G8B8A8_UNORM;
|
VkFormat format = VK_FORMAT_R8G8B8A8_UNORM;
|
||||||
VkPipelineRenderingCreateInfo rendering{
|
VkPipelineRenderingCreateInfo rendering{};
|
||||||
VK_STRUCTURE_TYPE_PIPELINE_RENDERING_CREATE_INFO};
|
rendering.sType = VK_STRUCTURE_TYPE_PIPELINE_RENDERING_CREATE_INFO;
|
||||||
rendering.colorAttachmentCount = shadow_pass ? 0 : 1;
|
rendering.colorAttachmentCount = shadow_pass ? 0 : 1;
|
||||||
rendering.pColorAttachmentFormats = &format;
|
rendering.pColorAttachmentFormats = &format;
|
||||||
rendering.depthAttachmentFormat = VK_FORMAT_D32_SFLOAT;
|
rendering.depthAttachmentFormat = VK_FORMAT_D32_SFLOAT;
|
||||||
VkGraphicsPipelineCreateInfo pi{VK_STRUCTURE_TYPE_GRAPHICS_PIPELINE_CREATE_INFO};
|
VkGraphicsPipelineCreateInfo pi{};
|
||||||
|
pi.sType = VK_STRUCTURE_TYPE_GRAPHICS_PIPELINE_CREATE_INFO;
|
||||||
pi.pNext = &rendering;
|
pi.pNext = &rendering;
|
||||||
pi.stageCount = shadow_pass ? 1 : 2;
|
pi.stageCount = shadow_pass ? 1 : 2;
|
||||||
pi.pStages = stages;
|
pi.pStages = stages;
|
||||||
@@ -822,7 +854,10 @@ struct Renderer::Impl {
|
|||||||
pi.pColorBlendState = &cb;
|
pi.pColorBlendState = &cb;
|
||||||
pi.pDynamicState = &dynamic;
|
pi.pDynamicState = &dynamic;
|
||||||
pi.layout = pipeline_layout;
|
pi.layout = pipeline_layout;
|
||||||
auto* output = shadow_pass ? &shadow_pipeline : ui ? &ui_pipeline : &pipeline;
|
auto* output = shadow_pass ? &shadow_pipeline
|
||||||
|
: ui ? &ui_pipeline
|
||||||
|
: sprite ? &sprite_pipeline
|
||||||
|
: &pipeline;
|
||||||
check(vkCreateGraphicsPipelines(device, VK_NULL_HANDLE, 1, &pi, nullptr, output),
|
check(vkCreateGraphicsPipelines(device, VK_NULL_HANDLE, 1, &pi, nullptr, output),
|
||||||
"Create graphics pipeline");
|
"Create graphics pipeline");
|
||||||
}
|
}
|
||||||
@@ -999,7 +1034,7 @@ struct Renderer::Impl {
|
|||||||
if (sprite.texture)
|
if (sprite.texture)
|
||||||
upload_texture(sprite.texture);
|
upload_texture(sprite.texture);
|
||||||
std::vector<GpuVertex> data;
|
std::vector<GpuVertex> data;
|
||||||
std::vector<Batch> scene_batches, shadow_batches, ui_batches;
|
std::vector<Batch> scene_batches, shadow_batches, sprite_batches, ui_batches;
|
||||||
for (const auto& item : snapshot.draws) {
|
for (const auto& item : snapshot.draws) {
|
||||||
if (!item.mesh)
|
if (!item.mesh)
|
||||||
continue;
|
continue;
|
||||||
@@ -1031,7 +1066,29 @@ struct Renderer::Impl {
|
|||||||
else
|
else
|
||||||
scene_batches.push_back(batch);
|
scene_batches.push_back(batch);
|
||||||
}
|
}
|
||||||
|
struct OrderedSprite {
|
||||||
|
const Sprite* sprite;
|
||||||
|
float depth;
|
||||||
|
};
|
||||||
|
std::vector<OrderedSprite> ordered_sprites;
|
||||||
|
ordered_sprites.reserve(snapshot.sprites.size());
|
||||||
for (const auto& sprite : snapshot.sprites) {
|
for (const auto& sprite : snapshot.sprites) {
|
||||||
|
const auto clip =
|
||||||
|
point(snapshot.view_projection,
|
||||||
|
{sprite.position[0], sprite.position[1], sprite.position[2], 1});
|
||||||
|
const auto depth =
|
||||||
|
clip[3] != 0 ? clip[2] / clip[3] : std::numeric_limits<float>::infinity();
|
||||||
|
ordered_sprites.push_back(
|
||||||
|
{&sprite, std::isfinite(depth) ? depth : std::numeric_limits<float>::infinity()});
|
||||||
|
}
|
||||||
|
std::stable_sort(ordered_sprites.begin(), ordered_sprites.end(),
|
||||||
|
[](const auto& a, const auto& b) {
|
||||||
|
if (a.sprite->layer != b.sprite->layer)
|
||||||
|
return a.sprite->layer < b.sprite->layer;
|
||||||
|
return a.depth > b.depth;
|
||||||
|
});
|
||||||
|
for (const auto& ordered : ordered_sprites) {
|
||||||
|
const auto& sprite = *ordered.sprite;
|
||||||
auto first = static_cast<std::uint32_t>(data.size());
|
auto first = static_cast<std::uint32_t>(data.size());
|
||||||
float c = std::cos(sprite.rotation), s = std::sin(sprite.rotation);
|
float c = std::cos(sprite.rotation), s = std::sin(sprite.rotation);
|
||||||
for (auto i : {0, 1, 2, 0, 2, 3}) {
|
for (auto i : {0, 1, 2, 0, 2, 3}) {
|
||||||
@@ -1048,7 +1105,7 @@ struct Renderer::Impl {
|
|||||||
vertex.material[0] = sprite.texture && sprite.texture->srgb ? 1.f : 0.f;
|
vertex.material[0] = sprite.texture && sprite.texture->srgb ? 1.f : 0.f;
|
||||||
data.push_back(vertex);
|
data.push_back(vertex);
|
||||||
}
|
}
|
||||||
scene_batches.push_back(
|
sprite_batches.push_back(
|
||||||
{first, 6, sprite.texture ? sprite.texture.get() : white.get()});
|
{first, 6, sprite.texture ? sprite.texture.get() : white.get()});
|
||||||
}
|
}
|
||||||
for (const auto& q : snapshot.ui_quads) {
|
for (const auto& q : snapshot.ui_quads) {
|
||||||
@@ -1131,13 +1188,15 @@ struct Renderer::Impl {
|
|||||||
graph.add("ShadowMap", {}, {"shadow"}, [&] {
|
graph.add("ShadowMap", {}, {"shadow"}, [&] {
|
||||||
transition(command, shadow, VK_IMAGE_LAYOUT_DEPTH_ATTACHMENT_OPTIMAL,
|
transition(command, shadow, VK_IMAGE_LAYOUT_DEPTH_ATTACHMENT_OPTIMAL,
|
||||||
VK_IMAGE_ASPECT_DEPTH_BIT);
|
VK_IMAGE_ASPECT_DEPTH_BIT);
|
||||||
VkRenderingAttachmentInfo attachment{VK_STRUCTURE_TYPE_RENDERING_ATTACHMENT_INFO};
|
VkRenderingAttachmentInfo attachment{};
|
||||||
|
attachment.sType = VK_STRUCTURE_TYPE_RENDERING_ATTACHMENT_INFO;
|
||||||
attachment.imageView = shadow.view;
|
attachment.imageView = shadow.view;
|
||||||
attachment.imageLayout = VK_IMAGE_LAYOUT_DEPTH_ATTACHMENT_OPTIMAL;
|
attachment.imageLayout = VK_IMAGE_LAYOUT_DEPTH_ATTACHMENT_OPTIMAL;
|
||||||
attachment.loadOp = VK_ATTACHMENT_LOAD_OP_CLEAR;
|
attachment.loadOp = VK_ATTACHMENT_LOAD_OP_CLEAR;
|
||||||
attachment.storeOp = VK_ATTACHMENT_STORE_OP_STORE;
|
attachment.storeOp = VK_ATTACHMENT_STORE_OP_STORE;
|
||||||
attachment.clearValue.depthStencil = {1, 0};
|
attachment.clearValue.depthStencil = {1, 0};
|
||||||
VkRenderingInfo rendering{VK_STRUCTURE_TYPE_RENDERING_INFO};
|
VkRenderingInfo rendering{};
|
||||||
|
rendering.sType = VK_STRUCTURE_TYPE_RENDERING_INFO;
|
||||||
rendering.renderArea = {{0, 0}, {shadow_size, shadow_size}};
|
rendering.renderArea = {{0, 0}, {shadow_size, shadow_size}};
|
||||||
rendering.layerCount = 1;
|
rendering.layerCount = 1;
|
||||||
rendering.pDepthAttachment = &attachment;
|
rendering.pDepthAttachment = &attachment;
|
||||||
@@ -1160,20 +1219,23 @@ struct Renderer::Impl {
|
|||||||
VK_IMAGE_ASPECT_COLOR_BIT);
|
VK_IMAGE_ASPECT_COLOR_BIT);
|
||||||
transition(command, depth, VK_IMAGE_LAYOUT_DEPTH_ATTACHMENT_OPTIMAL,
|
transition(command, depth, VK_IMAGE_LAYOUT_DEPTH_ATTACHMENT_OPTIMAL,
|
||||||
VK_IMAGE_ASPECT_DEPTH_BIT);
|
VK_IMAGE_ASPECT_DEPTH_BIT);
|
||||||
VkRenderingAttachmentInfo ca{VK_STRUCTURE_TYPE_RENDERING_ATTACHMENT_INFO};
|
VkRenderingAttachmentInfo ca{};
|
||||||
|
ca.sType = VK_STRUCTURE_TYPE_RENDERING_ATTACHMENT_INFO;
|
||||||
ca.imageView = color.view;
|
ca.imageView = color.view;
|
||||||
ca.imageLayout = VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL;
|
ca.imageLayout = VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL;
|
||||||
ca.loadOp = VK_ATTACHMENT_LOAD_OP_CLEAR;
|
ca.loadOp = VK_ATTACHMENT_LOAD_OP_CLEAR;
|
||||||
ca.storeOp = VK_ATTACHMENT_STORE_OP_STORE;
|
ca.storeOp = VK_ATTACHMENT_STORE_OP_STORE;
|
||||||
std::copy(snapshot.clear_color.begin(), snapshot.clear_color.end(),
|
std::copy(snapshot.clear_color.begin(), snapshot.clear_color.end(),
|
||||||
ca.clearValue.color.float32);
|
ca.clearValue.color.float32);
|
||||||
VkRenderingAttachmentInfo da{VK_STRUCTURE_TYPE_RENDERING_ATTACHMENT_INFO};
|
VkRenderingAttachmentInfo da{};
|
||||||
|
da.sType = VK_STRUCTURE_TYPE_RENDERING_ATTACHMENT_INFO;
|
||||||
da.imageView = depth.view;
|
da.imageView = depth.view;
|
||||||
da.imageLayout = VK_IMAGE_LAYOUT_DEPTH_ATTACHMENT_OPTIMAL;
|
da.imageLayout = VK_IMAGE_LAYOUT_DEPTH_ATTACHMENT_OPTIMAL;
|
||||||
da.loadOp = VK_ATTACHMENT_LOAD_OP_CLEAR;
|
da.loadOp = VK_ATTACHMENT_LOAD_OP_CLEAR;
|
||||||
da.storeOp = VK_ATTACHMENT_STORE_OP_DONT_CARE;
|
da.storeOp = VK_ATTACHMENT_STORE_OP_DONT_CARE;
|
||||||
da.clearValue.depthStencil = {1, 0};
|
da.clearValue.depthStencil = {1, 0};
|
||||||
VkRenderingInfo rendering{VK_STRUCTURE_TYPE_RENDERING_INFO};
|
VkRenderingInfo rendering{};
|
||||||
|
rendering.sType = VK_STRUCTURE_TYPE_RENDERING_INFO;
|
||||||
rendering.renderArea = {{0, 0}, {width, height}};
|
rendering.renderArea = {{0, 0}, {width, height}};
|
||||||
rendering.layerCount = 1;
|
rendering.layerCount = 1;
|
||||||
rendering.colorAttachmentCount = 1;
|
rendering.colorAttachmentCount = 1;
|
||||||
@@ -1205,6 +1267,14 @@ struct Renderer::Impl {
|
|||||||
vkCmdDraw(command, batch.count, 1, batch.first, 0);
|
vkCmdDraw(command, batch.count, 1, batch.first, 0);
|
||||||
++statistics.draw_calls;
|
++statistics.draw_calls;
|
||||||
}
|
}
|
||||||
|
vkCmdBindPipeline(command, VK_PIPELINE_BIND_POINT_GRAPHICS, sprite_pipeline);
|
||||||
|
for (auto batch : sprite_batches) {
|
||||||
|
auto descriptor = textures.at(batch.texture).descriptor;
|
||||||
|
vkCmdBindDescriptorSets(command, VK_PIPELINE_BIND_POINT_GRAPHICS, pipeline_layout,
|
||||||
|
0, 1, &descriptor, 0, nullptr);
|
||||||
|
vkCmdDraw(command, batch.count, 1, batch.first, 0);
|
||||||
|
++statistics.draw_calls;
|
||||||
|
}
|
||||||
set_viewport(width, height);
|
set_viewport(width, height);
|
||||||
vkCmdBindPipeline(command, VK_PIPELINE_BIND_POINT_GRAPHICS, ui_pipeline);
|
vkCmdBindPipeline(command, VK_PIPELINE_BIND_POINT_GRAPHICS, ui_pipeline);
|
||||||
vkCmdPushConstants(command, pipeline_layout,
|
vkCmdPushConstants(command, pipeline_layout,
|
||||||
@@ -1262,7 +1332,8 @@ struct Renderer::Impl {
|
|||||||
statistics.gpu_ms = double(delta) * timestamp_period / 1000000.0;
|
statistics.gpu_ms = double(delta) * timestamp_period / 1000000.0;
|
||||||
}
|
}
|
||||||
if (swap_index) {
|
if (swap_index) {
|
||||||
VkPresentInfoKHR present{VK_STRUCTURE_TYPE_PRESENT_INFO_KHR};
|
VkPresentInfoKHR present{};
|
||||||
|
present.sType = VK_STRUCTURE_TYPE_PRESENT_INFO_KHR;
|
||||||
present.waitSemaphoreCount = 1;
|
present.waitSemaphoreCount = 1;
|
||||||
present.pWaitSemaphores = &present_ready;
|
present.pWaitSemaphores = &present_ready;
|
||||||
present.swapchainCount = 1;
|
present.swapchainCount = 1;
|
||||||
@@ -1313,10 +1384,12 @@ bool Renderer::reload_shaders(std::string& error) {
|
|||||||
auto previous = r.pipeline;
|
auto previous = r.pipeline;
|
||||||
auto previous_ui = r.ui_pipeline;
|
auto previous_ui = r.ui_pipeline;
|
||||||
auto previous_shadow = r.shadow_pipeline;
|
auto previous_shadow = r.shadow_pipeline;
|
||||||
|
auto previous_sprite = r.sprite_pipeline;
|
||||||
r.pipeline_layout = {};
|
r.pipeline_layout = {};
|
||||||
r.pipeline = {};
|
r.pipeline = {};
|
||||||
r.ui_pipeline = {};
|
r.ui_pipeline = {};
|
||||||
r.shadow_pipeline = {};
|
r.shadow_pipeline = {};
|
||||||
|
r.sprite_pipeline = {};
|
||||||
try {
|
try {
|
||||||
r.make_pipelines();
|
r.make_pipelines();
|
||||||
} catch (const std::exception& exception) {
|
} catch (const std::exception& exception) {
|
||||||
@@ -1326,18 +1399,22 @@ bool Renderer::reload_shaders(std::string& error) {
|
|||||||
vkDestroyPipeline(r.device, r.ui_pipeline, nullptr);
|
vkDestroyPipeline(r.device, r.ui_pipeline, nullptr);
|
||||||
if (r.shadow_pipeline)
|
if (r.shadow_pipeline)
|
||||||
vkDestroyPipeline(r.device, r.shadow_pipeline, nullptr);
|
vkDestroyPipeline(r.device, r.shadow_pipeline, nullptr);
|
||||||
|
if (r.sprite_pipeline)
|
||||||
|
vkDestroyPipeline(r.device, r.sprite_pipeline, nullptr);
|
||||||
if (r.pipeline_layout)
|
if (r.pipeline_layout)
|
||||||
vkDestroyPipelineLayout(r.device, r.pipeline_layout, nullptr);
|
vkDestroyPipelineLayout(r.device, r.pipeline_layout, nullptr);
|
||||||
r.pipeline_layout = previous_layout;
|
r.pipeline_layout = previous_layout;
|
||||||
r.pipeline = previous;
|
r.pipeline = previous;
|
||||||
r.ui_pipeline = previous_ui;
|
r.ui_pipeline = previous_ui;
|
||||||
r.shadow_pipeline = previous_shadow;
|
r.shadow_pipeline = previous_shadow;
|
||||||
|
r.sprite_pipeline = previous_sprite;
|
||||||
error = exception.what();
|
error = exception.what();
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
vkDestroyPipeline(r.device, previous, nullptr);
|
vkDestroyPipeline(r.device, previous, nullptr);
|
||||||
vkDestroyPipeline(r.device, previous_ui, nullptr);
|
vkDestroyPipeline(r.device, previous_ui, nullptr);
|
||||||
vkDestroyPipeline(r.device, previous_shadow, nullptr);
|
vkDestroyPipeline(r.device, previous_shadow, nullptr);
|
||||||
|
vkDestroyPipeline(r.device, previous_sprite, nullptr);
|
||||||
vkDestroyPipelineLayout(r.device, previous_layout, nullptr);
|
vkDestroyPipelineLayout(r.device, previous_layout, nullptr);
|
||||||
error.clear();
|
error.clear();
|
||||||
return true;
|
return true;
|
||||||
@@ -1360,6 +1437,10 @@ std::uint32_t Renderer::width() const {
|
|||||||
std::uint32_t Renderer::height() const {
|
std::uint32_t Renderer::height() const {
|
||||||
return impl_->height;
|
return impl_->height;
|
||||||
}
|
}
|
||||||
|
float Renderer::display_scale() const {
|
||||||
|
const float scale = impl_->window ? SDL_GetWindowDisplayScale(impl_->window) : 1.f;
|
||||||
|
return std::isfinite(scale) && scale > 0.f ? scale : 1.f;
|
||||||
|
}
|
||||||
bool Renderer::should_close() const {
|
bool Renderer::should_close() const {
|
||||||
return impl_->close;
|
return impl_->close;
|
||||||
}
|
}
|
||||||
@@ -1372,9 +1453,9 @@ std::vector<std::uint8_t> Renderer::pixels() const {
|
|||||||
void Renderer::capture(const std::filesystem::path& path) {
|
void Renderer::capture(const std::filesystem::path& path) {
|
||||||
if (impl_->last_pixels.empty())
|
if (impl_->last_pixels.empty())
|
||||||
throw std::runtime_error("Cannot capture before a completed frame");
|
throw std::runtime_error("Cannot capture before a completed frame");
|
||||||
std::ofstream out(path, std::ios::binary);
|
std::ofstream out(faset::native_io_path(path), std::ios::binary);
|
||||||
if (!out)
|
if (!out)
|
||||||
throw std::runtime_error("Cannot write screenshot: " + path.string());
|
throw std::runtime_error("Cannot write screenshot: " + faset::path_to_utf8(path));
|
||||||
out << "P6\n" << width() << ' ' << height() << "\n255\n";
|
out << "P6\n" << width() << ' ' << height() << "\n255\n";
|
||||||
for (std::size_t i = 0; i < impl_->last_pixels.size(); i += 4)
|
for (std::size_t i = 0; i < impl_->last_pixels.size(); i += 4)
|
||||||
out.write(reinterpret_cast<const char*>(impl_->last_pixels.data() + i), 3);
|
out.write(reinterpret_cast<const char*>(impl_->last_pixels.data() + i), 3);
|
||||||
|
|||||||
@@ -14,8 +14,10 @@ void require(bool value, const std::string& message) {
|
|||||||
throw std::runtime_error("Shader contract: " + message);
|
throw std::runtime_error("Shader contract: " + message);
|
||||||
}
|
}
|
||||||
std::string read_bounded(const std::filesystem::path& path, std::uintmax_t maximum) {
|
std::string read_bounded(const std::filesystem::path& path, std::uintmax_t maximum) {
|
||||||
require(std::filesystem::is_regular_file(path), "missing " + path.string());
|
const auto native = faset::native_io_path(path);
|
||||||
require(std::filesystem::file_size(path) <= maximum, "oversized " + path.string());
|
require(std::filesystem::is_regular_file(native), "missing " + faset::path_to_utf8(path));
|
||||||
|
require(std::filesystem::file_size(native) <= maximum,
|
||||||
|
"oversized " + faset::path_to_utf8(path));
|
||||||
return faset::read_text(path);
|
return faset::read_text(path);
|
||||||
}
|
}
|
||||||
void locations(const Json& fields, std::initializer_list<const char*> types, const char* label) {
|
void locations(const Json& fields, std::initializer_list<const char*> types, const char* label) {
|
||||||
|
|||||||
+71
-2
@@ -5,12 +5,19 @@
|
|||||||
#include <deque>
|
#include <deque>
|
||||||
#include <entt/entt.hpp>
|
#include <entt/entt.hpp>
|
||||||
#include <faset/runtime/Runtime.hpp>
|
#include <faset/runtime/Runtime.hpp>
|
||||||
|
#include <faset/runtime/schema.hpp>
|
||||||
#include <numbers>
|
#include <numbers>
|
||||||
#include <set>
|
#include <set>
|
||||||
#include <stdexcept>
|
#include <stdexcept>
|
||||||
#include <unordered_map>
|
#include <unordered_map>
|
||||||
|
|
||||||
namespace faset::runtime {
|
namespace faset::runtime {
|
||||||
|
bool is_builtin_component(std::string_view type) noexcept {
|
||||||
|
constexpr std::array<std::string_view, 7> types{
|
||||||
|
"faset.transform", "faset.sprite", "faset.mesh", "faset.camera",
|
||||||
|
"faset.light", "faset.rigid_body_2d", "faset.rigid_body_3d"};
|
||||||
|
return std::find(types.begin(), types.end(), type) != types.end();
|
||||||
|
}
|
||||||
namespace {
|
namespace {
|
||||||
using Json = nlohmann::json;
|
using Json = nlohmann::json;
|
||||||
std::atomic<std::uint64_t> nextSession{1};
|
std::atomic<std::uint64_t> nextSession{1};
|
||||||
@@ -20,6 +27,20 @@ void require(bool condition, const std::string& message) {
|
|||||||
if (!condition)
|
if (!condition)
|
||||||
throw std::invalid_argument(message);
|
throw std::invalid_argument(message);
|
||||||
}
|
}
|
||||||
|
std::uint64_t schemaVersion(const Json& record, const std::string& context) {
|
||||||
|
if (!record.contains("version"))
|
||||||
|
return 1;
|
||||||
|
const auto& value = record.at("version");
|
||||||
|
if (value.is_number_unsigned()) {
|
||||||
|
const auto version = value.get<std::uint64_t>();
|
||||||
|
require(version > 0, context + ": version must be a positive integer");
|
||||||
|
return version;
|
||||||
|
}
|
||||||
|
require(value.is_number_integer(), context + ": version must be a positive integer");
|
||||||
|
const auto version = value.get<std::int64_t>();
|
||||||
|
require(version > 0, context + ": version must be a positive integer");
|
||||||
|
return static_cast<std::uint64_t>(version);
|
||||||
|
}
|
||||||
template <std::size_t N>
|
template <std::size_t N>
|
||||||
std::array<float, N> vectorValue(const Json& object, const char* key,
|
std::array<float, N> vectorValue(const Json& object, const char* key,
|
||||||
std::array<float, N> fallback) {
|
std::array<float, N> fallback) {
|
||||||
@@ -111,11 +132,13 @@ void validateEntity(const Json& entity, int dimension) {
|
|||||||
require(component.contains("type") && component["type"].is_string() &&
|
require(component.contains("type") && component["type"].is_string() &&
|
||||||
!component["type"].get<std::string>().empty(),
|
!component["type"].get<std::string>().empty(),
|
||||||
"component requires type");
|
"component requires type");
|
||||||
require(component.value("version", 1) == 1, "unsupported component version");
|
const auto type = component["type"].get<std::string>();
|
||||||
|
const auto version = schemaVersion(component, "component " + type);
|
||||||
|
require(!is_builtin_component(type) || version == 1,
|
||||||
|
"unsupported builtin component version: " + type);
|
||||||
require(component.contains("fields") && component["fields"].is_object(),
|
require(component.contains("fields") && component["fields"].is_object(),
|
||||||
"component requires fields");
|
"component requires fields");
|
||||||
require(ids.insert(component["id"].get<std::string>()).second, "duplicate component id");
|
require(ids.insert(component["id"].get<std::string>()).second, "duplicate component id");
|
||||||
const auto type = component["type"].get<std::string>();
|
|
||||||
require(types.insert(type).second, "duplicate component type");
|
require(types.insert(type).second, "duplicate component type");
|
||||||
const auto& f = component["fields"];
|
const auto& f = component["fields"];
|
||||||
if (type == "faset.transform")
|
if (type == "faset.transform")
|
||||||
@@ -196,6 +219,52 @@ Transform interpolate(const Transform& a, const Transform& b, float alpha) {
|
|||||||
}
|
}
|
||||||
} // namespace
|
} // namespace
|
||||||
|
|
||||||
|
void validate_scene_schemas(const Json& scene, const Json& gameplay_schema) {
|
||||||
|
require(gameplay_schema.is_array() ||
|
||||||
|
(gameplay_schema.is_object() && gameplay_schema.contains("types") &&
|
||||||
|
gameplay_schema.at("types").is_array()),
|
||||||
|
"gameplay schema must be an array or a types manifest");
|
||||||
|
const auto& schemas =
|
||||||
|
gameplay_schema.is_array() ? gameplay_schema : gameplay_schema.at("types");
|
||||||
|
std::unordered_map<std::string, std::uint64_t> available;
|
||||||
|
for (const auto& schema : schemas) {
|
||||||
|
require(schema.is_object() && schema.contains("id") && schema.at("id").is_string() &&
|
||||||
|
!schema.at("id").get<std::string>().empty(),
|
||||||
|
"gameplay schema requires a nonempty TypeId");
|
||||||
|
const auto id = schema.at("id").get<std::string>();
|
||||||
|
const auto version = schemaVersion(schema, "schema " + id);
|
||||||
|
require(!is_builtin_component(id),
|
||||||
|
"gameplay schema duplicates a builtin component TypeId: " + id);
|
||||||
|
require(available.emplace(id, version).second, "duplicate gameplay schema TypeId: " + id);
|
||||||
|
}
|
||||||
|
require(scene.is_object() && scene.contains("entities") && scene.at("entities").is_array(),
|
||||||
|
"scene entities must be an array");
|
||||||
|
for (const auto& entity : scene.at("entities")) {
|
||||||
|
require(entity.is_object() && entity.contains("components") &&
|
||||||
|
entity.at("components").is_array(),
|
||||||
|
"entity components must be an array");
|
||||||
|
for (const auto& component : entity.at("components")) {
|
||||||
|
require(component.is_object() && component.contains("type") &&
|
||||||
|
component.at("type").is_string() &&
|
||||||
|
!component.at("type").get<std::string>().empty(),
|
||||||
|
"component requires a nonempty TypeId");
|
||||||
|
const auto type = component.at("type").get<std::string>();
|
||||||
|
const auto version = schemaVersion(component, "component " + type);
|
||||||
|
if (is_builtin_component(type)) {
|
||||||
|
require(version == 1, "unsupported builtin component version: " + type);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const auto found = available.find(type);
|
||||||
|
require(found != available.end(),
|
||||||
|
"component schema is absent from linked gameplay module: " + type);
|
||||||
|
require(found->second == version, "component schema version mismatch: " + type +
|
||||||
|
" (scene " + std::to_string(version) +
|
||||||
|
", linked gameplay " +
|
||||||
|
std::to_string(found->second) + ")");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
struct Runtime::Impl {
|
struct Runtime::Impl {
|
||||||
struct Data {
|
struct Data {
|
||||||
Json document;
|
Json document;
|
||||||
|
|||||||
+8
-2
@@ -1,3 +1,4 @@
|
|||||||
|
#include <faset/core/io.hpp>
|
||||||
#include <faset/ui/ui.hpp>
|
#include <faset/ui/ui.hpp>
|
||||||
#include <ft2build.h>
|
#include <ft2build.h>
|
||||||
#include FT_FREETYPE_H
|
#include FT_FREETYPE_H
|
||||||
@@ -6,6 +7,7 @@
|
|||||||
#include <fstream>
|
#include <fstream>
|
||||||
#include <hb-ft.h>
|
#include <hb-ft.h>
|
||||||
#include <hb.h>
|
#include <hb.h>
|
||||||
|
#include <limits>
|
||||||
#include <map>
|
#include <map>
|
||||||
#include <stdexcept>
|
#include <stdexcept>
|
||||||
|
|
||||||
@@ -41,12 +43,16 @@ struct FontAtlas::Impl {
|
|||||||
std::map<std::pair<unsigned, unsigned>, Glyph> glyphs;
|
std::map<std::pair<unsigned, unsigned>, Glyph> glyphs;
|
||||||
unsigned x = 2, y = 2, row_height = 0, size = 0;
|
unsigned x = 2, y = 2, row_height = 0, size = 0;
|
||||||
explicit Impl(const std::filesystem::path& path) {
|
explicit Impl(const std::filesystem::path& path) {
|
||||||
std::ifstream in(path, std::ios::binary | std::ios::ate);
|
// FreeType's Windows path backend uses CreateFileA. Read a native path
|
||||||
|
// ourselves and retain its bytes until hb_font/FT_Face are destroyed.
|
||||||
|
std::ifstream in(native_io_path(path), std::ios::binary | std::ios::ate);
|
||||||
if (!in)
|
if (!in)
|
||||||
throw std::runtime_error("Cannot open UI font: " + path.string());
|
throw std::runtime_error("Cannot open UI font: " + path_to_utf8(path));
|
||||||
auto length = in.tellg();
|
auto length = in.tellg();
|
||||||
if (length <= 0)
|
if (length <= 0)
|
||||||
throw std::runtime_error("Empty UI font");
|
throw std::runtime_error("Empty UI font");
|
||||||
|
if (length > std::numeric_limits<FT_Long>::max())
|
||||||
|
throw std::runtime_error("UI font exceeds FreeType's supported buffer size");
|
||||||
font_bytes.resize(static_cast<std::size_t>(length));
|
font_bytes.resize(static_cast<std::size_t>(length));
|
||||||
in.seekg(0);
|
in.seekg(0);
|
||||||
in.read(reinterpret_cast<char*>(font_bytes.data()), length);
|
in.read(reinterpret_cast<char*>(font_bytes.data()), length);
|
||||||
|
|||||||
+18
-1
@@ -739,9 +739,26 @@ void Context::apply_layout(const Json& document) {
|
|||||||
apply(root(), definition);
|
apply(root(), definition);
|
||||||
}
|
}
|
||||||
void Context::layout(float width, float height, float scale) {
|
void Context::layout(float width, float height, float scale) {
|
||||||
|
if (!std::isfinite(width) || !std::isfinite(height) || !std::isfinite(scale) || scale <= 0)
|
||||||
|
throw std::invalid_argument("UI dimensions and display scale must be finite and valid");
|
||||||
|
scale = std::clamp(scale, .5f, 4.f);
|
||||||
|
if (impl_->scale != scale) {
|
||||||
|
// Pointer captures use the old drawable coordinate system. Keep edits,
|
||||||
|
// but cancel a drag rather than committing a jump after a monitor change.
|
||||||
|
impl_->cancel_capture();
|
||||||
|
const auto ratio = scale / impl_->scale;
|
||||||
|
std::function<void(Widget&)> rescale = [&](Widget& widget) {
|
||||||
|
widget.scroll_y *= ratio;
|
||||||
|
for (auto& child : widget.children)
|
||||||
|
rescale(*child);
|
||||||
|
};
|
||||||
|
rescale(root());
|
||||||
|
for (auto& [id, edit] : impl_->edits)
|
||||||
|
edit.scroll *= ratio;
|
||||||
|
}
|
||||||
impl_->width = std::max(0.f, width);
|
impl_->width = std::max(0.f, width);
|
||||||
impl_->height = std::max(0.f, height);
|
impl_->height = std::max(0.f, height);
|
||||||
impl_->scale = std::clamp(scale, .5f, 4.f);
|
impl_->scale = scale;
|
||||||
std::set<std::string> ids;
|
std::set<std::string> ids;
|
||||||
std::function<void(Widget&)> check = [&](Widget& w) {
|
std::function<void(Widget&)> check = [&](Widget& w) {
|
||||||
if (w.id.empty() || !ids.insert(w.id).second)
|
if (w.id.empty() || !ids.insert(w.id).second)
|
||||||
|
|||||||
+34
-17
@@ -3,6 +3,7 @@
|
|||||||
#include <chrono>
|
#include <chrono>
|
||||||
#include <faset/assets/asset_pipeline.hpp>
|
#include <faset/assets/asset_pipeline.hpp>
|
||||||
#include <faset/core/hash.hpp>
|
#include <faset/core/hash.hpp>
|
||||||
|
#include <faset/core/io.hpp>
|
||||||
#include <fstream>
|
#include <fstream>
|
||||||
#include <iostream>
|
#include <iostream>
|
||||||
#include <stdexcept>
|
#include <stdexcept>
|
||||||
@@ -102,13 +103,15 @@ void success(const ImportResult& result) {
|
|||||||
}
|
}
|
||||||
} // namespace
|
} // namespace
|
||||||
int main() {
|
int main() {
|
||||||
const auto root = fs::temp_directory_path() /
|
const auto root =
|
||||||
("faset-assets-test-" +
|
fs::temp_directory_path() /
|
||||||
std::to_string(std::chrono::steady_clock::now().time_since_epoch().count()));
|
faset::path_from_utf8(
|
||||||
|
"Faset Café 世界 assets " +
|
||||||
|
std::to_string(std::chrono::steady_clock::now().time_since_epoch().count()));
|
||||||
fs::create_directories(root);
|
fs::create_directories(root);
|
||||||
try {
|
try {
|
||||||
AssetPipeline pipeline(root / "cache");
|
AssetPipeline pipeline(root / "cache");
|
||||||
const auto source = root / "door.glb";
|
const auto source = root / faset::path_from_utf8("дверь 世界.glb");
|
||||||
glb(source);
|
glb(source);
|
||||||
auto first = pipeline.import_asset({source});
|
auto first = pipeline.import_asset({source});
|
||||||
success(first);
|
success(first);
|
||||||
@@ -171,6 +174,19 @@ int main() {
|
|||||||
require(pipeline.overrides(first.asset_id) == custom, "conflict erased overrides");
|
require(pipeline.overrides(first.asset_id) == custom, "conflict erased overrides");
|
||||||
ImportRequest resolve{source};
|
ImportRequest resolve{source};
|
||||||
resolve.allow_removed_outputs = true;
|
resolve.allow_removed_outputs = true;
|
||||||
|
resolve.expected_generation = removed.generation;
|
||||||
|
resolve.expected_active_generation = added.generation;
|
||||||
|
glb(source, "Renamed panel", true, false, .5f);
|
||||||
|
auto changed_since_review = pipeline.import_asset(resolve);
|
||||||
|
require(changed_since_review.status == ImportStatus::conflict &&
|
||||||
|
pipeline.load_asset(first.asset_id).generation == added.generation,
|
||||||
|
"review approval must not publish a changed candidate");
|
||||||
|
glb(source, "Renamed panel", true, false, .25f);
|
||||||
|
resolve.expected_active_generation = first.generation;
|
||||||
|
require(pipeline.import_asset(resolve).status == ImportStatus::conflict &&
|
||||||
|
pipeline.load_asset(first.asset_id).generation == added.generation,
|
||||||
|
"review approval must not replace a different active generation");
|
||||||
|
resolve.expected_active_generation = added.generation;
|
||||||
auto resolved = pipeline.import_asset(resolve);
|
auto resolved = pipeline.import_asset(resolve);
|
||||||
success(resolved);
|
success(resolved);
|
||||||
require(pipeline.load_asset(first.asset_id).nodes.size() == 1,
|
require(pipeline.load_asset(first.asset_id).nodes.size() == 1,
|
||||||
@@ -207,14 +223,14 @@ int main() {
|
|||||||
"ambiguous rename silently matched");
|
"ambiguous rename silently matched");
|
||||||
auto duplicate = document("Duplicate", true, true);
|
auto duplicate = document("Duplicate", true, true);
|
||||||
duplicate["nodes"][1]["extras"]["faset_id"] = "node-door";
|
duplicate["nodes"][1]["extras"]["faset_id"] = "node-door";
|
||||||
duplicate["buffers"][0]["uri"] = "mesh.bin";
|
duplicate["buffers"][0]["uri"] = "геометрия.bin";
|
||||||
save(root / "mesh.bin", geometry(0));
|
save(root / faset::path_from_utf8("геометрия.bin"), geometry(0));
|
||||||
save(root / "duplicate.gltf", duplicate.dump());
|
save(root / "duplicate.gltf", duplicate.dump());
|
||||||
require(pipeline.import_asset({root / "duplicate.gltf"}).status == ImportStatus::failed,
|
require(pipeline.import_asset({root / "duplicate.gltf"}).status == ImportStatus::failed,
|
||||||
"duplicate source ID accepted");
|
"duplicate source ID accepted");
|
||||||
auto external = document("External", true, false);
|
auto external = document("External", true, false);
|
||||||
external["buffers"][0]["uri"] = "mesh.bin";
|
external["buffers"][0]["uri"] = "геометрия.bin";
|
||||||
external["images"] = Json::array({{{"uri", "pixel.png"}, {"mimeType", "image/png"}}});
|
external["images"] = Json::array({{{"uri", "пиксель.png"}, {"mimeType", "image/png"}}});
|
||||||
external["textures"] = Json::array({{{"source", 0}}});
|
external["textures"] = Json::array({{{"source", 0}}});
|
||||||
external["materials"][0]["pbrMetallicRoughness"]["baseColorTexture"] = {{"index", 0}};
|
external["materials"][0]["pbrMetallicRoughness"]["baseColorTexture"] = {{"index", 0}};
|
||||||
// Real 1x1 PNG payload, transparent pixel; importer owns encoded bytes.
|
// Real 1x1 PNG payload, transparent pixel; importer owns encoded bytes.
|
||||||
@@ -223,7 +239,7 @@ int main() {
|
|||||||
0, 0, 1, 0, 0, 0, 1, 8, 6, 0, 0, 0, 31, 21, 196, 137, 0,
|
0, 0, 1, 0, 0, 0, 1, 8, 6, 0, 0, 0, 31, 21, 196, 137, 0,
|
||||||
0, 0, 11, 73, 68, 65, 84, 120, 156, 99, 96, 0, 2, 0, 0, 5, 0,
|
0, 0, 11, 73, 68, 65, 84, 120, 156, 99, 96, 0, 2, 0, 0, 5, 0,
|
||||||
1, 165, 246, 69, 64, 0, 0, 0, 0, 73, 69, 78, 68, 174, 66, 96, 130};
|
1, 165, 246, 69, 64, 0, 0, 0, 0, 73, 69, 78, 68, 174, 66, 96, 130};
|
||||||
save(root / "pixel.png", png);
|
save(root / faset::path_from_utf8("пиксель.png"), png);
|
||||||
save(root / "external.gltf", external.dump());
|
save(root / "external.gltf", external.dump());
|
||||||
auto ext = pipeline.import_asset({root / "external.gltf"});
|
auto ext = pipeline.import_asset({root / "external.gltf"});
|
||||||
success(ext);
|
success(ext);
|
||||||
@@ -231,7 +247,7 @@ int main() {
|
|||||||
require(ext_asset.textures.size() == 1 && ext_asset.textures[0].bytes.size() == png.size(),
|
require(ext_asset.textures.size() == 1 && ext_asset.textures[0].bytes.size() == png.size(),
|
||||||
"external image not extracted");
|
"external image not extracted");
|
||||||
require(ext_asset.materials[0].base_color_texture == 0, "material texture reference lost");
|
require(ext_asset.materials[0].base_color_texture == 0, "material texture reference lost");
|
||||||
save(root / "mesh.bin", geometry(.3f));
|
save(root / faset::path_from_utf8("геометрия.bin"), geometry(.3f));
|
||||||
auto dependent = pipeline.import_asset({root / "external.gltf"});
|
auto dependent = pipeline.import_asset({root / "external.gltf"});
|
||||||
success(dependent);
|
success(dependent);
|
||||||
require(dependent.generation != ext.generation, "buffer dependency not invalidated");
|
require(dependent.generation != ext.generation, "buffer dependency not invalidated");
|
||||||
@@ -269,7 +285,7 @@ int main() {
|
|||||||
const auto dependency_active = pipeline.load_asset(ext.asset_id).generation;
|
const auto dependency_active = pipeline.load_asset(ext.asset_id).generation;
|
||||||
ImportJob mutate([&](const ImportProgress& progress) {
|
ImportJob mutate([&](const ImportProgress& progress) {
|
||||||
if (progress.fraction == .5f)
|
if (progress.fraction == .5f)
|
||||||
save(root / "mesh.bin", geometry(.7f));
|
save(root / faset::path_from_utf8("геометрия.bin"), geometry(.7f));
|
||||||
});
|
});
|
||||||
require(pipeline.import_asset({root / "external.gltf"}, mutate).status ==
|
require(pipeline.import_asset({root / "external.gltf"}, mutate).status ==
|
||||||
ImportStatus::failed,
|
ImportStatus::failed,
|
||||||
@@ -277,7 +293,7 @@ int main() {
|
|||||||
require(pipeline.load_asset(ext.asset_id).generation == dependency_active,
|
require(pipeline.load_asset(ext.asset_id).generation == dependency_active,
|
||||||
"concurrent edit changed active");
|
"concurrent edit changed active");
|
||||||
// Standalone images share the same identity, generation and failure guarantees.
|
// Standalone images share the same identity, generation and failure guarantees.
|
||||||
const auto picture = root / "sprite.PNG";
|
const auto picture = root / faset::path_from_utf8("спрайт Café.PNG");
|
||||||
save(picture, faset::test_images::png_red_green);
|
save(picture, faset::test_images::png_red_green);
|
||||||
ImportRequest image_request{picture};
|
ImportRequest image_request{picture};
|
||||||
image_request.settings = {{"pixels_per_unit", 20.0}};
|
image_request.settings = {{"pixels_per_unit", 20.0}};
|
||||||
@@ -345,15 +361,16 @@ int main() {
|
|||||||
save(picture, faset::test_images::png_blue_white);
|
save(picture, faset::test_images::png_blue_white);
|
||||||
const auto renamed = root / "renamed.PNG";
|
const auto renamed = root / "renamed.PNG";
|
||||||
fs::rename(picture, renamed);
|
fs::rename(picture, renamed);
|
||||||
fs::rename(picture.string() + ".faset-import.json",
|
fs::rename(faset::path_from_utf8(faset::path_to_utf8(picture) + ".faset-import.json"),
|
||||||
renamed.string() + ".faset-import.json");
|
faset::path_from_utf8(faset::path_to_utf8(renamed) + ".faset-import.json"));
|
||||||
fs::rename(picture.string() + ".faset-overrides.json",
|
fs::rename(faset::path_from_utf8(faset::path_to_utf8(picture) + ".faset-overrides.json"),
|
||||||
renamed.string() + ".faset-overrides.json");
|
faset::path_from_utf8(faset::path_to_utf8(renamed) + ".faset-overrides.json"));
|
||||||
auto image_rename = pipeline.import_asset({renamed});
|
auto image_rename = pipeline.import_asset({renamed});
|
||||||
success(image_rename);
|
success(image_rename);
|
||||||
require(image_rename.cache_hit && image_rename.asset_id == image_first.asset_id,
|
require(image_rename.cache_hit && image_rename.asset_id == image_first.asset_id,
|
||||||
"Image source rename with sidecar lost identity/cache");
|
"Image source rename with sidecar lost identity/cache");
|
||||||
require(pipeline.current_manifest(image_first.asset_id)["source"] == renamed.string(),
|
require(pipeline.current_manifest(image_first.asset_id)["source"] ==
|
||||||
|
faset::path_to_utf8(renamed),
|
||||||
"Image rename retained old source pointer");
|
"Image rename retained old source pointer");
|
||||||
require(pipeline.overrides(image_first.asset_id).contains(texture_id),
|
require(pipeline.overrides(image_first.asset_id).contains(texture_id),
|
||||||
"Image rename/reimport lost overrides");
|
"Image rename/reimport lost overrides");
|
||||||
|
|||||||
+136
-22
@@ -11,6 +11,12 @@
|
|||||||
#ifndef _WIN32
|
#ifndef _WIN32
|
||||||
#include <csignal>
|
#include <csignal>
|
||||||
#endif
|
#endif
|
||||||
|
#ifdef __linux__
|
||||||
|
#include <cerrno>
|
||||||
|
#include <sys/prctl.h>
|
||||||
|
#include <sys/wait.h>
|
||||||
|
#include <unistd.h>
|
||||||
|
#endif
|
||||||
using namespace faset;
|
using namespace faset;
|
||||||
namespace fs = std::filesystem;
|
namespace fs = std::filesystem;
|
||||||
void require(bool value, const char* message) {
|
void require(bool value, const char* message) {
|
||||||
@@ -38,7 +44,7 @@ int integration(const fs::path& root) {
|
|||||||
fs::create_directories(root);
|
fs::create_directories(root);
|
||||||
editor::BuildConfig config;
|
editor::BuildConfig config;
|
||||||
config.project_root = root / "project";
|
config.project_root = root / "project";
|
||||||
config.engine_root = FASET_ENGINE_SOURCE;
|
config.engine_root = path_from_utf8(FASET_ENGINE_SOURCE);
|
||||||
config.build_directory = root / "native-build";
|
config.build_directory = root / "native-build";
|
||||||
config.cache_root = root / "project" / ".faset" / "cache";
|
config.cache_root = root / "project" / ".faset" / "cache";
|
||||||
editor::BuildService service(config);
|
editor::BuildService service(config);
|
||||||
@@ -114,18 +120,19 @@ int integration(const fs::path& root) {
|
|||||||
{"components", components}});
|
{"components", components}});
|
||||||
auto result =
|
auto result =
|
||||||
wait(service.start_export(document, root / ("export-" + std::to_string(dimension))));
|
wait(service.start_export(document, root / ("export-" + std::to_string(dimension))));
|
||||||
const auto directory = fs::path(result.result.at("directory").get<std::string>());
|
const auto directory = path_from_utf8(result.result.at("directory").get<std::string>());
|
||||||
require(result.result.at("configuration") == "Release",
|
require(result.result.at("configuration") == "Release",
|
||||||
"Exports default to the Release profile");
|
"Exports default to the Release profile");
|
||||||
require(fs::path(result.result.at("build_directory").get<std::string>()) ==
|
require(path_from_utf8(result.result.at("build_directory").get<std::string>()) ==
|
||||||
config.build_directory / "Release",
|
config.build_directory / "Release",
|
||||||
"Export has a separate CMake directory");
|
"Export has a separate CMake directory");
|
||||||
require(read_json(directory / "manifest.json").at("configuration") == "Release",
|
require(read_json(directory / "manifest.json").at("configuration") == "Release",
|
||||||
"Export manifest records the actual profile");
|
"Export manifest records the actual profile");
|
||||||
Process player({{result.result.at("executable").get<std::string>(), "--headless",
|
Process player(
|
||||||
"--frames", "3", "--capture", (directory / "verification.ppm").string()},
|
{{result.result.at("executable").get<std::string>(), "--headless", "--frames", "3",
|
||||||
directory,
|
"--capture", path_to_utf8(directory / "verification.ppm")},
|
||||||
{}});
|
directory,
|
||||||
|
{}});
|
||||||
std::cout << collect(player);
|
std::cout << collect(player);
|
||||||
require(fs::file_size(directory / "verification.ppm") > 1000,
|
require(fs::file_size(directory / "verification.ppm") > 1000,
|
||||||
"Exported game rendered a frame");
|
"Exported game rendered a frame");
|
||||||
@@ -139,12 +146,12 @@ int integration(const fs::path& root) {
|
|||||||
auto release_cache = read_text(config.build_directory / "Release" / "CMakeCache.txt");
|
auto release_cache = read_text(config.build_directory / "Release" / "CMakeCache.txt");
|
||||||
auto rebuilt = wait(service.start_build());
|
auto rebuilt = wait(service.start_build());
|
||||||
require(rebuilt.result.at("configuration") == "Debug", "Development builds remain Debug");
|
require(rebuilt.result.at("configuration") == "Debug", "Development builds remain Debug");
|
||||||
require(fs::path(rebuilt.result.at("build_directory").get<std::string>()) ==
|
require(path_from_utf8(rebuilt.result.at("build_directory").get<std::string>()) ==
|
||||||
config.build_directory / "Debug",
|
config.build_directory / "Debug",
|
||||||
"Development CMake directory is isolated");
|
"Development CMake directory is isolated");
|
||||||
require(read_text(config.build_directory / "Release" / "CMakeCache.txt") == release_cache,
|
require(read_text(config.build_directory / "Release" / "CMakeCache.txt") == release_cache,
|
||||||
"Development build preserves the Release cache");
|
"Development build preserves the Release cache");
|
||||||
auto schema = read_json(rebuilt.result.at("schema").get<std::string>());
|
auto schema = read_json(path_from_utf8(rebuilt.result.at("schema").get<std::string>()));
|
||||||
bool updated{};
|
bool updated{};
|
||||||
for (const auto& type : schema.at("types"))
|
for (const auto& type : schema.at("types"))
|
||||||
if (type.value("name", "") == "Custom Character")
|
if (type.value("name", "") == "Custom Character")
|
||||||
@@ -162,13 +169,104 @@ int integration(const fs::path& root) {
|
|||||||
"schema and failed-build recovery passed\n";
|
"schema and failed-build recovery passed\n";
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
int main(int argc, char** argv) {
|
#ifdef __linux__
|
||||||
|
void descendant_cleanup(const fs::path& executable, const fs::path& directory) {
|
||||||
|
struct Subreaper {
|
||||||
|
int previous{};
|
||||||
|
Subreaper() {
|
||||||
|
require(::prctl(PR_GET_CHILD_SUBREAPER, &previous) == 0 &&
|
||||||
|
::prctl(PR_SET_CHILD_SUBREAPER, 1) == 0,
|
||||||
|
"Install test-only descendant reaper");
|
||||||
|
}
|
||||||
|
~Subreaper() {
|
||||||
|
::prctl(PR_SET_CHILD_SUBREAPER, previous);
|
||||||
|
}
|
||||||
|
} subreaper;
|
||||||
|
for (bool cancel : {false, true}) {
|
||||||
|
Process process(
|
||||||
|
{{path_to_utf8(executable), cancel ? "--tree-sleep" : "--tree-exit"}, directory, {}});
|
||||||
|
std::string output;
|
||||||
|
bool stopped{};
|
||||||
|
const auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(3);
|
||||||
|
while (std::chrono::steady_clock::now() < deadline) {
|
||||||
|
auto state = process.poll();
|
||||||
|
output += state.output;
|
||||||
|
if (cancel && output.find("leader-tail\n") != std::string::npos) {
|
||||||
|
process.cancel();
|
||||||
|
state = process.poll();
|
||||||
|
output += state.output;
|
||||||
|
}
|
||||||
|
if (!state.running) {
|
||||||
|
require(cancel || state.exit_code == 0, "Leader exit status remains intact");
|
||||||
|
stopped = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
std::this_thread::sleep_for(std::chrono::milliseconds(5));
|
||||||
|
}
|
||||||
|
require(stopped && output.find("leader-tail\n") != std::string::npos,
|
||||||
|
"Leader exit and buffered stdout tail are observed");
|
||||||
|
const auto descendant = static_cast<pid_t>(std::stol(output));
|
||||||
|
int status{};
|
||||||
|
pid_t reaped{};
|
||||||
|
const auto reap_deadline = std::chrono::steady_clock::now() + std::chrono::seconds(2);
|
||||||
|
do {
|
||||||
|
reaped = ::waitpid(descendant, &status, WNOHANG);
|
||||||
|
if (reaped != 0)
|
||||||
|
break;
|
||||||
|
std::this_thread::sleep_for(std::chrono::milliseconds(5));
|
||||||
|
} while (std::chrono::steady_clock::now() < reap_deadline);
|
||||||
|
const bool cleaned =
|
||||||
|
reaped == descendant && WIFSIGNALED(status) && WTERMSIG(status) == SIGKILL;
|
||||||
|
if (reaped == 0) {
|
||||||
|
// Clean up the still-owned child even when this regression fails.
|
||||||
|
::kill(descendant, SIGKILL);
|
||||||
|
while (::waitpid(descendant, &status, 0) < 0 && errno == EINTR) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
require(cleaned, "Leader completion terminates its SIGTERM-resistant descendant");
|
||||||
|
process.cancel(); // Completed ownership must not signal any stale process-group ID.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
int test_main(int argc, char** argv) {
|
||||||
|
#ifdef __linux__
|
||||||
|
if (argc > 1 &&
|
||||||
|
(std::string(argv[1]) == "--tree-exit" || std::string(argv[1]) == "--tree-sleep")) {
|
||||||
|
int ready[2];
|
||||||
|
if (::pipe(ready) != 0)
|
||||||
|
return 2;
|
||||||
|
const auto descendant = ::fork();
|
||||||
|
if (descendant < 0)
|
||||||
|
return 3;
|
||||||
|
if (descendant == 0) {
|
||||||
|
::close(ready[0]);
|
||||||
|
std::signal(SIGTERM, SIG_IGN);
|
||||||
|
const char byte = 'r';
|
||||||
|
if (::write(ready[1], &byte, 1) != 1)
|
||||||
|
::_exit(4);
|
||||||
|
::close(ready[1]);
|
||||||
|
while (true)
|
||||||
|
::pause();
|
||||||
|
}
|
||||||
|
::close(ready[1]);
|
||||||
|
char byte{};
|
||||||
|
const auto count = ::read(ready[0], &byte, 1);
|
||||||
|
::close(ready[0]);
|
||||||
|
if (count != 1)
|
||||||
|
return 5;
|
||||||
|
std::cout << descendant << "\nleader-tail\n" << std::flush;
|
||||||
|
if (std::string(argv[1]) == "--tree-sleep")
|
||||||
|
while (true)
|
||||||
|
::pause();
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
#endif
|
||||||
if (argc > 1 && std::string(argv[1]) == "--child") {
|
if (argc > 1 && std::string(argv[1]) == "--child") {
|
||||||
Json args = Json::array();
|
Json args = Json::array();
|
||||||
for (int i = 2; i < argc; ++i)
|
for (int i = 2; i < argc; ++i)
|
||||||
args.push_back(argv[i]);
|
args.push_back(argv[i]);
|
||||||
std::cout << Json{{"args", args},
|
std::cout << Json{{"args", args},
|
||||||
{"cwd", fs::current_path().string()},
|
{"cwd", path_to_utf8(fs::current_path())},
|
||||||
{"env", std::getenv("FASET_PROCESS_TEST")
|
{"env", std::getenv("FASET_PROCESS_TEST")
|
||||||
? std::getenv("FASET_PROCESS_TEST")
|
? std::getenv("FASET_PROCESS_TEST")
|
||||||
: ""}}
|
: ""}}
|
||||||
@@ -188,32 +286,35 @@ int main(int argc, char** argv) {
|
|||||||
}
|
}
|
||||||
if (argc == 3 && std::string(argv[1]) == "--integration") {
|
if (argc == 3 && std::string(argv[1]) == "--integration") {
|
||||||
try {
|
try {
|
||||||
return integration(fs::absolute(argv[2]));
|
return integration(fs::absolute(path_from_utf8(argv[2])));
|
||||||
} catch (const std::exception& error) {
|
} catch (const std::exception& error) {
|
||||||
std::cerr << error.what() << '\n';
|
std::cerr << error.what() << '\n';
|
||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
fs::path temporary = fs::temp_directory_path() / ("Faset build test " + new_id());
|
fs::path temporary = fs::temp_directory_path() / path_from_utf8("Faset Café 世界 " + new_id());
|
||||||
try {
|
try {
|
||||||
fs::create_directories(temporary);
|
fs::create_directories(temporary);
|
||||||
auto executable = fs::absolute(argv[0]);
|
const auto original_executable = fs::absolute(path_from_utf8(argv[0]));
|
||||||
Process child({{executable.string(), "--child", "space argument", "quote\"backslash\\",
|
const auto executable = temporary / original_executable.filename();
|
||||||
"$(touch not-executed); & |", ""},
|
fs::copy_file(original_executable, executable);
|
||||||
|
Process child({{path_to_utf8(executable), "--child", "space argument", "quote\"backslash\\",
|
||||||
|
"$(touch not-executed); & |", "", "Café 世界 Привет 😀"},
|
||||||
temporary,
|
temporary,
|
||||||
{{"FASET_PROCESS_TEST", "value with spaces"}}});
|
{{"FASET_PROCESS_TEST", "value with spaces"}}});
|
||||||
auto text = collect(child);
|
auto text = collect(child);
|
||||||
auto result = Json::parse(text.substr(0, text.find('\n')));
|
auto result = Json::parse(text.substr(0, text.find('\n')));
|
||||||
require(result["args"] == Json::array({"space argument", "quote\"backslash\\",
|
require(result["args"] ==
|
||||||
"$(touch not-executed); & |", ""}),
|
Json::array({"space argument", "quote\"backslash\\",
|
||||||
|
"$(touch not-executed); & |", "", "Café 世界 Привет 😀"}),
|
||||||
"Arguments must remain literal");
|
"Arguments must remain literal");
|
||||||
require(result["env"] == "value with spaces", "Child environment override");
|
require(result["env"] == "value with spaces", "Child environment override");
|
||||||
require(fs::equivalent(result["cwd"].get<std::string>(), temporary),
|
require(fs::equivalent(path_from_utf8(result["cwd"].get<std::string>()), temporary),
|
||||||
"Child working directory");
|
"Child working directory");
|
||||||
require(text.find("stderr-sentinel") != std::string::npos && text.size() > 100000,
|
require(text.find("stderr-sentinel") != std::string::npos && text.size() > 100000,
|
||||||
"Combined pipe output drained fully");
|
"Combined pipe output drained fully");
|
||||||
require(!fs::exists(temporary / "not-executed"), "No shell execution");
|
require(!fs::exists(temporary / "not-executed"), "No shell execution");
|
||||||
Process sleeper({{executable.string(), "--sleep"}, temporary, {}});
|
Process sleeper({{path_to_utf8(executable), "--sleep"}, temporary, {}});
|
||||||
bool ready{};
|
bool ready{};
|
||||||
while (!ready) {
|
while (!ready) {
|
||||||
auto p = sleeper.poll();
|
auto p = sleeper.poll();
|
||||||
@@ -226,9 +327,12 @@ int main(int argc, char** argv) {
|
|||||||
require(!sleeper.poll().running, "Cancellation must reap the process");
|
require(!sleeper.poll().running, "Cancellation must reap the process");
|
||||||
require(std::chrono::steady_clock::now() - start < std::chrono::seconds(3),
|
require(std::chrono::steady_clock::now() - start < std::chrono::seconds(3),
|
||||||
"Cancellation must finish promptly");
|
"Cancellation must finish promptly");
|
||||||
|
#ifdef __linux__
|
||||||
|
descendant_cleanup(executable, temporary);
|
||||||
|
#endif
|
||||||
editor::BuildConfig config;
|
editor::BuildConfig config;
|
||||||
config.project_root = temporary / "project";
|
config.project_root = temporary / "project";
|
||||||
config.engine_root = FASET_ENGINE_SOURCE;
|
config.engine_root = path_from_utf8(FASET_ENGINE_SOURCE);
|
||||||
editor::BuildService service(config);
|
editor::BuildService service(config);
|
||||||
service.scaffold("Test project", 2);
|
service.scaffold("Test project", 2);
|
||||||
atomic_write(config.project_root / "Scripts" / "Gameplay.cpp", "// User code\n");
|
atomic_write(config.project_root / "Scripts" / "Gameplay.cpp", "// User code\n");
|
||||||
@@ -238,7 +342,7 @@ int main(int argc, char** argv) {
|
|||||||
auto id = service.start_cook(scene(2));
|
auto id = service.start_cook(scene(2));
|
||||||
auto cooked = service.wait(id);
|
auto cooked = service.wait(id);
|
||||||
require(cooked.state == "succeeded", "Cook job succeeds");
|
require(cooked.state == "succeeded", "Cook job succeeds");
|
||||||
auto bytes = read_text(cooked.result.at("scene").get<std::string>());
|
auto bytes = read_text(path_from_utf8(cooked.result.at("scene").get<std::string>()));
|
||||||
require(bytes.substr(0, 8) == "FASETSCN" && bytes.size() > 20, "Cooked envelope magic");
|
require(bytes.substr(0, 8) == "FASETSCN" && bytes.size() > 20, "Cooked envelope magic");
|
||||||
std::uint32_t version{};
|
std::uint32_t version{};
|
||||||
std::uint64_t size{};
|
std::uint64_t size{};
|
||||||
@@ -287,3 +391,13 @@ int main(int argc, char** argv) {
|
|||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#ifdef _WIN32
|
||||||
|
int wmain(int argc, wchar_t** argv) {
|
||||||
|
return faset::run_utf8_main(argc, argv, test_main);
|
||||||
|
}
|
||||||
|
#else
|
||||||
|
int main(int argc, char** argv) {
|
||||||
|
return test_main(argc, argv);
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|||||||
+40
-2
@@ -33,6 +33,44 @@ int main() {
|
|||||||
faset::sha256(faset::read_text(directory / "state.json")));
|
faset::sha256(faset::read_text(directory / "state.json")));
|
||||||
CHECK(std::filesystem::equivalent(faset::project_path(directory, "assets/../state.json"),
|
CHECK(std::filesystem::equivalent(faset::project_path(directory, "assets/../state.json"),
|
||||||
directory / "state.json"));
|
directory / "state.json"));
|
||||||
|
const auto unicode = directory / faset::path_from_utf8("Faset Café 世界/Сцена 😀.json");
|
||||||
|
faset::atomic_write_json(unicode, {{"hello", "Unicode paths"}});
|
||||||
|
faset::atomic_write_json(unicode, {{"hello", "Replaced"}});
|
||||||
|
CHECK(faset::read_json(unicode).at("hello") == "Replaced");
|
||||||
|
CHECK(faset::sha256_file(unicode) == faset::sha256(faset::read_text(unicode)));
|
||||||
|
CHECK(faset::path_from_utf8(faset::path_to_utf8(unicode)) == unicode);
|
||||||
|
CHECK(faset::generic_path_to_utf8(unicode.lexically_relative(directory)) ==
|
||||||
|
"Faset Café 世界/Сцена 😀.json");
|
||||||
|
CHECK(std::filesystem::equivalent(
|
||||||
|
faset::project_path(directory, faset::path_from_utf8("Faset Café 世界/Сцена 😀.json")),
|
||||||
|
unicode));
|
||||||
|
for (const auto& invalid :
|
||||||
|
std::vector<std::string>{std::string("a\0b", 3), "\xc0\xaf", "\xed\xa0\x80",
|
||||||
|
"\xf4\x90\x80\x80", "\xe2\x82", "\x80"}) {
|
||||||
|
bool invalid_rejected = false;
|
||||||
|
try {
|
||||||
|
faset::path_from_utf8(invalid);
|
||||||
|
} catch (const faset::Error& error) {
|
||||||
|
invalid_rejected = error.json().at("code") == "path.encoding";
|
||||||
|
}
|
||||||
|
CHECK(invalid_rejected);
|
||||||
|
}
|
||||||
|
auto deep = directory / faset::path_from_utf8("Long Café 世界");
|
||||||
|
while (deep.native().size() < 310)
|
||||||
|
deep /= "directory-segment-0123456789";
|
||||||
|
const auto long_file = deep / faset::path_from_utf8("Сцена 世界.json");
|
||||||
|
faset::atomic_write_json(long_file, {{"long_path", true}});
|
||||||
|
CHECK(faset::read_json(long_file).at("long_path") == true);
|
||||||
|
CHECK(faset::sha256_file(long_file) == faset::sha256(faset::read_text(long_file)));
|
||||||
|
CHECK(std::filesystem::is_regular_file(faset::native_io_path(long_file)));
|
||||||
|
CHECK(faset::path_to_utf8(long_file).find("\\\\?\\") == std::string::npos);
|
||||||
|
#ifdef _WIN32
|
||||||
|
CHECK(faset::native_io_path(long_file).native().starts_with(LR"(\\?\)"));
|
||||||
|
CHECK(faset::native_io_path(std::filesystem::path(LR"(\\server\share\file.txt)")) ==
|
||||||
|
std::filesystem::path(LR"(\\?\UNC\server\share\file.txt)"));
|
||||||
|
CHECK(faset::native_io_path(faset::native_io_path(long_file)) ==
|
||||||
|
faset::native_io_path(long_file));
|
||||||
|
#endif
|
||||||
bool rejected = false;
|
bool rejected = false;
|
||||||
try {
|
try {
|
||||||
faset::project_path(directory, "../escape");
|
faset::project_path(directory, "../escape");
|
||||||
@@ -47,12 +85,12 @@ int main() {
|
|||||||
rejected = true;
|
rejected = true;
|
||||||
}
|
}
|
||||||
CHECK(rejected);
|
CHECK(rejected);
|
||||||
std::filesystem::remove_all(directory);
|
std::filesystem::remove_all(faset::native_io_path(directory));
|
||||||
std::cout << "Core: SHA-256 vectors, persistent IDs, durable replace, Unicode, path "
|
std::cout << "Core: SHA-256 vectors, persistent IDs, durable replace, Unicode, path "
|
||||||
"boundaries passed\n";
|
"boundaries passed\n";
|
||||||
return 0;
|
return 0;
|
||||||
} catch (const std::exception& error) {
|
} catch (const std::exception& error) {
|
||||||
std::filesystem::remove_all(directory);
|
std::filesystem::remove_all(faset::native_io_path(directory));
|
||||||
std::cerr << error.what() << '\n';
|
std::cerr << error.what() << '\n';
|
||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,144 @@
|
|||||||
|
#include <chrono>
|
||||||
|
#include <faset/core/io.hpp>
|
||||||
|
#include <faset/editor/editor_ui.hpp>
|
||||||
|
#include <iostream>
|
||||||
|
#include <thread>
|
||||||
|
|
||||||
|
using namespace faset;
|
||||||
|
namespace fs = std::filesystem;
|
||||||
|
namespace {
|
||||||
|
void check(bool value, const char* message) {
|
||||||
|
if (!value)
|
||||||
|
throw std::runtime_error(message);
|
||||||
|
}
|
||||||
|
Json geometry(const render::DrawItem& draw) {
|
||||||
|
Json positions = Json::array();
|
||||||
|
for (const auto& vertex : draw.mesh->vertices)
|
||||||
|
positions.push_back(vertex.position);
|
||||||
|
return positions;
|
||||||
|
}
|
||||||
|
int run(int argc, char** argv) {
|
||||||
|
try {
|
||||||
|
check(argc == 3, "Usage: faset_blender_editor_probe FIXTURE_DIRECTORY NEW_PROJECT");
|
||||||
|
const auto fixture = path_from_utf8(argv[1]), project = path_from_utf8(argv[2]);
|
||||||
|
check(!fs::exists(project), "Use a new project directory for the Blender UI probe");
|
||||||
|
const auto source = project / "Assets/door";
|
||||||
|
fs::create_directories(source);
|
||||||
|
editor::Session session({project, path_from_utf8(FASET_TEST_ENGINE), project});
|
||||||
|
render::Renderer renderer({.width = 1280,
|
||||||
|
.height = 900,
|
||||||
|
.title = "Blender reimport",
|
||||||
|
.headless = true,
|
||||||
|
.validation = true});
|
||||||
|
editor::EditorUI ui(session, renderer,
|
||||||
|
path_from_utf8(FASET_TEST_ENGINE) / "assets/fonts/NotoSans.ttf",
|
||||||
|
path_from_utf8(FASET_TEST_ENGINE) / "assets/ui/dark.json");
|
||||||
|
auto import = [&](const char* stage) {
|
||||||
|
fs::copy(fixture / stage, source,
|
||||||
|
fs::copy_options::recursive | fs::copy_options::overwrite_existing);
|
||||||
|
const auto job = session.commands()
|
||||||
|
.call("faset_import", {{"path", "Assets/door/manifest.json"}})
|
||||||
|
.at("job");
|
||||||
|
const auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(30);
|
||||||
|
while (std::chrono::steady_clock::now() < deadline) {
|
||||||
|
ui.frame({});
|
||||||
|
auto result = session.commands().call("faset_job", {{"id", job}});
|
||||||
|
if (result.at("state") != "queued" && result.at("state") != "running")
|
||||||
|
return result;
|
||||||
|
std::this_thread::sleep_for(std::chrono::milliseconds(10));
|
||||||
|
}
|
||||||
|
throw std::runtime_error("Blender import timed out");
|
||||||
|
};
|
||||||
|
const auto initial = import("initial");
|
||||||
|
check(initial.at("state") == "succeeded", "Initial Blender import failed");
|
||||||
|
const auto id = initial.at("result").at("asset_id");
|
||||||
|
const auto document = ui.current_document();
|
||||||
|
auto current = session.authoring().query(document);
|
||||||
|
Json operations = Json::array();
|
||||||
|
for (int index = 0; index < 2; ++index) {
|
||||||
|
auto entity = authoring::make_entity(session.authoring().schemas(), "Placed door");
|
||||||
|
entity["components"][0]["fields"]["position"] = {index ? 3 : -3, 0, 0};
|
||||||
|
entity["components"].push_back(
|
||||||
|
{{"id", new_id()},
|
||||||
|
{"type", "faset.mesh"},
|
||||||
|
{"version", 1},
|
||||||
|
{"fields",
|
||||||
|
{{"asset", id},
|
||||||
|
{"primitive", "asset"},
|
||||||
|
{"color", index ? Json{0.5, 0.8, 0.3, 1} : Json{0.3, 0.5, 0.9, 1}}}}});
|
||||||
|
// Missing gameplay packages stay opaque; reimport must not remove them.
|
||||||
|
entity["components"].push_back({{"id", new_id()},
|
||||||
|
{"type", "project.door"},
|
||||||
|
{"version", 2},
|
||||||
|
{"fields", {{"locked", index == 0}, {"speed", 2.5}}}});
|
||||||
|
auto body = session.authoring().schemas().default_fields("faset.rigid_body_3d");
|
||||||
|
body["body_type"] = "static";
|
||||||
|
body["friction"] = index ? 0.75 : 0.25;
|
||||||
|
entity["components"].push_back({{"id", new_id()},
|
||||||
|
{"type", "faset.rigid_body_3d"},
|
||||||
|
{"version", 1},
|
||||||
|
{"fields", body}});
|
||||||
|
operations.push_back({{"op", "entity.create"}, {"entity", entity}});
|
||||||
|
}
|
||||||
|
session.authoring().transact(document, current.at("revision"), operations);
|
||||||
|
current = session.authoring().query(document);
|
||||||
|
ui.frame({});
|
||||||
|
check(ui.snapshot().draws.size() >= 2, "Both placed Blender meshes must render");
|
||||||
|
const auto first = ui.snapshot().draws[0], second = ui.snapshot().draws[1];
|
||||||
|
check(first.mesh == second.mesh && first.model != second.model,
|
||||||
|
"Two independent placements must share the imported mesh");
|
||||||
|
renderer.render(ui.snapshot());
|
||||||
|
renderer.capture(project / "initial.ppm");
|
||||||
|
const auto renamed = import("renamed");
|
||||||
|
check(renamed.at("state") == "succeeded", "Renamed Blender import failed");
|
||||||
|
// Exercise the real editor's generation watcher, without manually clearing SceneView.
|
||||||
|
std::this_thread::sleep_for(std::chrono::milliseconds(1100));
|
||||||
|
ui.frame({});
|
||||||
|
const auto first_after = ui.snapshot().draws[0], second_after = ui.snapshot().draws[1];
|
||||||
|
check(geometry(first) != geometry(first_after) &&
|
||||||
|
geometry(second) != geometry(second_after),
|
||||||
|
"Both live Editor instances must receive changed Blender geometry");
|
||||||
|
check(first_after.mesh == second_after.mesh, "New instances share one cooked mesh");
|
||||||
|
check(first.model == first_after.model && second.model == second_after.model &&
|
||||||
|
first.color == first_after.color && second.color == second_after.color,
|
||||||
|
"Reimport must preserve each instance's placement and tint");
|
||||||
|
check(session.authoring().query(document) == current,
|
||||||
|
"Reimport must preserve authoring IDs, gameplay fields and revision");
|
||||||
|
renderer.render(ui.snapshot());
|
||||||
|
renderer.capture(project / "renamed.ppm");
|
||||||
|
const auto removed = import("removed");
|
||||||
|
check(removed.at("state") == "conflict", "Removed Blender output must conflict");
|
||||||
|
std::this_thread::sleep_for(std::chrono::milliseconds(1100));
|
||||||
|
ui.frame({});
|
||||||
|
check(geometry(ui.snapshot().draws[0]) == geometry(first_after) &&
|
||||||
|
geometry(ui.snapshot().draws[1]) == geometry(second_after),
|
||||||
|
"Conflict must keep both last-good visible meshes");
|
||||||
|
check(session.authoring().query(document) == current,
|
||||||
|
"Import conflict must preserve authoring");
|
||||||
|
check(renderer.stats().validation_errors == 0, "Vulkan validation failed");
|
||||||
|
atomic_write_json(project / "probe.json",
|
||||||
|
{{"status", "passed"},
|
||||||
|
{"instances", 2},
|
||||||
|
{"live_geometry_updated", true},
|
||||||
|
{"placement_and_tint_preserved", true},
|
||||||
|
{"opaque_gameplay_preserved", true},
|
||||||
|
{"physics_fields_preserved", true},
|
||||||
|
{"conflict_keeps_visible_generation", true},
|
||||||
|
{"validation_enabled", renderer.stats().validation_enabled},
|
||||||
|
{"validation_errors", renderer.stats().validation_errors}});
|
||||||
|
return 0;
|
||||||
|
} catch (const std::exception& error) {
|
||||||
|
std::cerr << error.what() << '\n';
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} // namespace
|
||||||
|
#ifdef _WIN32
|
||||||
|
int wmain(int argc, wchar_t** argv) {
|
||||||
|
return run_utf8_main(argc, argv, run);
|
||||||
|
}
|
||||||
|
#else
|
||||||
|
int main(int argc, char** argv) {
|
||||||
|
return run(argc, argv);
|
||||||
|
}
|
||||||
|
#endif
|
||||||
@@ -4,12 +4,21 @@
|
|||||||
|
|
||||||
int main() {
|
int main() {
|
||||||
using namespace faset;
|
using namespace faset;
|
||||||
const auto root = std::filesystem::temp_directory_path() / ("faset-session-" + new_id());
|
const auto root = std::filesystem::temp_directory_path() /
|
||||||
|
path_from_utf8("Faset Café 世界 session " + new_id());
|
||||||
try {
|
try {
|
||||||
editor::Session session({root, FASET_TEST_ENGINE, {}});
|
editor::Session session({root, path_from_utf8(FASET_TEST_ENGINE), {}});
|
||||||
session.scaffold("Settings", 3);
|
session.scaffold("Settings", 3);
|
||||||
auto document = session.authoring().create("Start", 3);
|
auto document = session.authoring().create("Start", 3);
|
||||||
session.authoring().save(document.at("id"), "Scenes/main.scene.json");
|
session.commands().call("faset_document_save", {{"document", document.at("id")},
|
||||||
|
{"path", "Scenes/Начало 世界.scene.json"}});
|
||||||
|
require(session.authoring().query(document.at("id")).at("path") ==
|
||||||
|
"Scenes/Начало 世界.scene.json",
|
||||||
|
"test", "Saved path must round-trip as UTF-8");
|
||||||
|
require(std::filesystem::is_regular_file(root /
|
||||||
|
path_from_utf8("Scenes/Начало 世界.scene.json")),
|
||||||
|
"test", "Unicode scene was saved under the wrong filename");
|
||||||
|
session.commands().call("faset_document_open", {{"path", "Scenes/Начало 世界.scene.json"}});
|
||||||
auto& commands = session.commands();
|
auto& commands = session.commands();
|
||||||
const auto initial = commands.call("faset_project_settings_get", Json::object());
|
const auto initial = commands.call("faset_project_settings_get", Json::object());
|
||||||
auto changed = commands.call("faset_project_settings_set",
|
auto changed = commands.call("faset_project_settings_set",
|
||||||
@@ -17,7 +26,7 @@ int main() {
|
|||||||
{"settings",
|
{"settings",
|
||||||
{{"name", "Проект 世界"},
|
{{"name", "Проект 世界"},
|
||||||
{"dimension", 2},
|
{"dimension", 2},
|
||||||
{"start_scene", "Scenes/main.scene.json"}}}});
|
{"start_scene", "Scenes/Начало 世界.scene.json"}}}});
|
||||||
require(changed.at("settings").at("name") == "Проект 世界" &&
|
require(changed.at("settings").at("name") == "Проект 世界" &&
|
||||||
session.project().at("dimension") == 2,
|
session.project().at("dimension") == 2,
|
||||||
"test", "Project settings were not saved");
|
"test", "Project settings were not saved");
|
||||||
|
|||||||
@@ -43,11 +43,11 @@ void near(float a, float b, const char* message) {
|
|||||||
int main() {
|
int main() {
|
||||||
const auto root = std::filesystem::temp_directory_path() / ("faset-gizmo-ui-" + new_id());
|
const auto root = std::filesystem::temp_directory_path() / ("faset-gizmo-ui-" + new_id());
|
||||||
try {
|
try {
|
||||||
editor::Session session({root, FASET_TEST_ENGINE, root});
|
editor::Session session({root, path_from_utf8(FASET_TEST_ENGINE), root});
|
||||||
render::Renderer renderer({1280, 900, "Parented gizmo acceptance", true, true});
|
render::Renderer renderer({1280, 900, "Parented gizmo acceptance", true, true});
|
||||||
editor::EditorUI ui(session, renderer,
|
editor::EditorUI ui(session, renderer,
|
||||||
std::filesystem::path(FASET_TEST_ENGINE) / "assets/fonts/NotoSans.ttf",
|
path_from_utf8(FASET_TEST_ENGINE) / "assets/fonts/NotoSans.ttf",
|
||||||
std::filesystem::path(FASET_TEST_ENGINE) / "assets/ui/dark.json");
|
path_from_utf8(FASET_TEST_ENGINE) / "assets/ui/dark.json");
|
||||||
ui.frame({});
|
ui.frame({});
|
||||||
auto query = [&] { return session.authoring().query(ui.current_document()); };
|
auto query = [&] { return session.authoring().query(ui.current_document()); };
|
||||||
auto parent =
|
auto parent =
|
||||||
@@ -158,10 +158,10 @@ int main() {
|
|||||||
check(renderer.stats().validation_errors == 0, "Vulkan validation");
|
check(renderer.stats().validation_errors == 0, "Vulkan validation");
|
||||||
std::cout << "Parented world Move/local Rotate/local Scale, multi-frame preview, Undo, "
|
std::cout << "Parented world Move/local Rotate/local Scale, multi-frame preview, Undo, "
|
||||||
"Escape and composed local rotation passed. "
|
"Escape and composed local rotation passed. "
|
||||||
<< root << '\n';
|
<< path_to_utf8(root) << '\n';
|
||||||
return 0;
|
return 0;
|
||||||
} catch (const std::exception& error) {
|
} catch (const std::exception& error) {
|
||||||
std::cerr << error.what() << "\nRetained: " << root << '\n';
|
std::cerr << error.what() << "\nRetained: " << path_to_utf8(root) << '\n';
|
||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,167 @@
|
|||||||
|
#include <chrono>
|
||||||
|
#include <faset/core/io.hpp>
|
||||||
|
#include <faset/editor/editor_ui.hpp>
|
||||||
|
#include <iostream>
|
||||||
|
#include <set>
|
||||||
|
#include <thread>
|
||||||
|
using namespace faset;
|
||||||
|
namespace {
|
||||||
|
void check(bool value, const std::string& message) {
|
||||||
|
if (!value)
|
||||||
|
throw std::runtime_error(message);
|
||||||
|
}
|
||||||
|
void click(editor::EditorUI& ui, const std::string& id) {
|
||||||
|
for (int attempt = 0; attempt < 40; ++attempt) {
|
||||||
|
const auto* w = ui.widgets().find(id);
|
||||||
|
check(w, "Missing widget: " + id);
|
||||||
|
const auto visible = w->rect.intersection(w->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;
|
||||||
|
ui.frame({down, up});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
auto* parent = w->parent;
|
||||||
|
while (parent && !parent->layout.scroll)
|
||||||
|
parent = parent->parent;
|
||||||
|
check(parent, "Cannot scroll to " + id);
|
||||||
|
render::Event move, wheel;
|
||||||
|
move.type = render::Event::Type::MouseMove;
|
||||||
|
move.x = parent->rect.x + 10;
|
||||||
|
move.y = parent->rect.y + 10;
|
||||||
|
wheel.type = render::Event::Type::Wheel;
|
||||||
|
wheel.y = w->rect.y < parent->rect.y ? 1 : -1;
|
||||||
|
ui.frame({move, wheel});
|
||||||
|
}
|
||||||
|
throw std::runtime_error("Widget remains clipped: " + id);
|
||||||
|
}
|
||||||
|
Json job(editor::Session& session, const std::string& id) {
|
||||||
|
return session.commands().call("faset_job", {{"id", id}});
|
||||||
|
}
|
||||||
|
Json wait(editor::Session& session, editor::EditorUI& ui, const std::string& id) {
|
||||||
|
for (int i = 0; i < 1000; ++i) {
|
||||||
|
ui.frame({});
|
||||||
|
const auto current = job(session, id);
|
||||||
|
if (current.at("state") != "queued" && current.at("state") != "running")
|
||||||
|
return current;
|
||||||
|
std::this_thread::sleep_for(std::chrono::milliseconds(5));
|
||||||
|
}
|
||||||
|
throw std::runtime_error("Import did not finish");
|
||||||
|
}
|
||||||
|
std::set<std::string> jobs(editor::Session& session) {
|
||||||
|
std::set<std::string> result;
|
||||||
|
const auto response = session.commands().call("faset_jobs", Json::object());
|
||||||
|
for (const auto& value : response.at("jobs"))
|
||||||
|
result.insert(value.at("id"));
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
std::string new_job(editor::Session& session, const std::set<std::string>& before) {
|
||||||
|
for (const auto& id : jobs(session))
|
||||||
|
if (!before.contains(id))
|
||||||
|
return id;
|
||||||
|
throw std::runtime_error("UI did not submit an import job");
|
||||||
|
}
|
||||||
|
} // namespace
|
||||||
|
int main() {
|
||||||
|
const auto root = std::filesystem::temp_directory_path() / ("faset-import-ui-" + new_id());
|
||||||
|
try {
|
||||||
|
editor::Session session({root, path_from_utf8(FASET_TEST_ENGINE), root});
|
||||||
|
render::Renderer renderer({1280, 900, "Import conflict workflow", true, true});
|
||||||
|
editor::EditorUI ui(session, renderer,
|
||||||
|
path_from_utf8(FASET_TEST_ENGINE) / "assets/fonts/NotoSans.ttf",
|
||||||
|
path_from_utf8(FASET_TEST_ENGINE) / "assets/ui/dark.json");
|
||||||
|
const auto source = root / "Assets/model.gltf";
|
||||||
|
Json gltf = {
|
||||||
|
{"asset", {{"version", "2.0"}}},
|
||||||
|
{"scene", 0},
|
||||||
|
{"scenes", Json::array({{{"nodes", {0, 1}}}})},
|
||||||
|
{"nodes",
|
||||||
|
Json::array({{{"name", "Keep"}, {"extras", {{"faset_id", new_id()}}}},
|
||||||
|
{{"name", "Removed node"}, {"extras", {{"faset_id", new_id()}}}}})}};
|
||||||
|
atomic_write_json(source, gltf);
|
||||||
|
const auto initial_id = session.commands()
|
||||||
|
.call("faset_import", {{"path", "Assets/model.gltf"},
|
||||||
|
{"settings", {{"units", "metres"}}}})
|
||||||
|
.at("job")
|
||||||
|
.get<std::string>();
|
||||||
|
const auto initial = wait(session, ui, initial_id);
|
||||||
|
check(initial.at("state") == "succeeded", "Fixture initial import: " + initial.dump());
|
||||||
|
const auto asset_id = initial.at("result").at("asset_id").get<std::string>();
|
||||||
|
const auto initial_generation = initial.at("result").at("generation");
|
||||||
|
assets::AssetStore store(root / ".faset/cache");
|
||||||
|
const auto document_before = session.authoring().query(ui.current_document());
|
||||||
|
gltf["nodes"].erase(gltf["nodes"].end() - 1);
|
||||||
|
gltf["scenes"][0]["nodes"] = Json::array({0});
|
||||||
|
atomic_write_json(source, gltf);
|
||||||
|
click(ui, "tab-assets");
|
||||||
|
click(ui, "asset-refresh");
|
||||||
|
click(ui, "file-Assets/model.gltf");
|
||||||
|
auto before = jobs(session);
|
||||||
|
click(ui, "asset-import");
|
||||||
|
const auto conflict_id = new_job(session, before);
|
||||||
|
auto conflict = wait(session, ui, conflict_id);
|
||||||
|
check(conflict.at("state") == "conflict", "Deleted output must remain a conflict");
|
||||||
|
click(ui, "tab-jobs");
|
||||||
|
click(ui, "job-review-" + conflict_id);
|
||||||
|
const auto group = "import-conflict-" + conflict_id;
|
||||||
|
const auto removed_id = conflict["result"]["removed_output_ids"][0].get<std::string>();
|
||||||
|
const auto* output = ui.widgets().find(group + "-output-" + removed_id);
|
||||||
|
check(output && output->text.find("Removed node") != std::string::npos,
|
||||||
|
"Review lists exact removed identity and its last-good name");
|
||||||
|
check(store.current_manifest(asset_id).at("generation") == initial_generation,
|
||||||
|
"Showing a conflict must not accept the removal");
|
||||||
|
renderer.render(ui.snapshot());
|
||||||
|
renderer.capture(root / "import-removal-review.ppm");
|
||||||
|
gltf["nodes"][0]["translation"] = {1, 0, 0};
|
||||||
|
atomic_write_json(source, gltf);
|
||||||
|
before = jobs(session);
|
||||||
|
click(ui, group + "-accept");
|
||||||
|
const auto stale_id = new_job(session, before);
|
||||||
|
const auto stale = wait(session, ui, stale_id);
|
||||||
|
check(stale.at("state") == "conflict" &&
|
||||||
|
store.current_manifest(asset_id).at("generation") == initial_generation,
|
||||||
|
"Changed source refuses reviewed acceptance and keeps the old generation: " +
|
||||||
|
stale.dump());
|
||||||
|
click(ui, "tab-conflicts");
|
||||||
|
const auto stale_group = "import-conflict-" + stale_id;
|
||||||
|
check(ui.widgets().find(stale_group + "-diagnostic")->text.find("changed since review") !=
|
||||||
|
std::string::npos,
|
||||||
|
"Stale acceptance exposes the changed-generation diagnostic and new review");
|
||||||
|
before = jobs(session);
|
||||||
|
click(ui, stale_group + "-retry");
|
||||||
|
const auto fresh_id = new_job(session, before);
|
||||||
|
const auto fresh = wait(session, ui, fresh_id);
|
||||||
|
check(fresh.at("state") == "conflict", "Reimport creates a fresh removal review");
|
||||||
|
click(ui, "tab-jobs");
|
||||||
|
click(ui, "job-review-" + fresh_id);
|
||||||
|
check(!ui.widgets().find(group), "New conflict supersedes the explicitly retried review");
|
||||||
|
before = jobs(session);
|
||||||
|
click(ui, "import-conflict-" + fresh_id + "-accept");
|
||||||
|
const auto accepted_id = new_job(session, before);
|
||||||
|
const auto accepted = wait(session, ui, accepted_id);
|
||||||
|
check(accepted.at("state") == "succeeded", "Explicit reviewed removal must publish");
|
||||||
|
check(store.current_manifest(asset_id).at("generation") == fresh["result"]["generation"],
|
||||||
|
"UI publishes exactly the reviewed candidate");
|
||||||
|
check(store.current_manifest(asset_id).at("settings").at("units") == "metres",
|
||||||
|
"Retry preserves the saved import settings");
|
||||||
|
click(ui, "tab-conflicts");
|
||||||
|
check(ui.widgets().find("conflicts-empty"),
|
||||||
|
"Resolved import conflicts leave the review list");
|
||||||
|
check(session.authoring().query(ui.current_document()) == document_before,
|
||||||
|
"Import approval never rewrites the authoring scene or its Undo revision");
|
||||||
|
renderer.render(ui.snapshot());
|
||||||
|
check(renderer.stats().validation_errors == 0, "Vulkan validation");
|
||||||
|
std::cout << "Import UI: removed IDs/names, stale review rejection, explicit retry and "
|
||||||
|
"exact-generation acceptance passed. "
|
||||||
|
<< path_to_utf8(root) << '\n';
|
||||||
|
return 0;
|
||||||
|
} catch (const std::exception& error) {
|
||||||
|
std::cerr << error.what() << "\nRetained: " << path_to_utf8(root) << '\n';
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
+106
-19
@@ -1,8 +1,45 @@
|
|||||||
|
#include <cstdlib>
|
||||||
#include <faset/core/io.hpp>
|
#include <faset/core/io.hpp>
|
||||||
#include <faset/editor/project_launcher.hpp>
|
#include <faset/editor/project_launcher.hpp>
|
||||||
#include <iostream>
|
#include <iostream>
|
||||||
using namespace faset;
|
using namespace faset;
|
||||||
namespace {
|
namespace {
|
||||||
|
class ScopedPathEnvironment {
|
||||||
|
public:
|
||||||
|
#ifdef _WIN32
|
||||||
|
ScopedPathEnvironment(const wchar_t* name, const std::filesystem::path& value) : name_(name) {
|
||||||
|
if (const auto* previous = _wgetenv(name))
|
||||||
|
old_ = previous;
|
||||||
|
if (_wputenv_s(name, value.c_str()) != 0)
|
||||||
|
throw std::runtime_error("Cannot set test environment");
|
||||||
|
}
|
||||||
|
~ScopedPathEnvironment() {
|
||||||
|
_wputenv_s(name_.c_str(), old_.c_str());
|
||||||
|
}
|
||||||
|
|
||||||
|
private:
|
||||||
|
std::wstring name_, old_;
|
||||||
|
#else
|
||||||
|
ScopedPathEnvironment(const char* name, const std::filesystem::path& value) : name_(name) {
|
||||||
|
if (const auto* previous = std::getenv(name)) {
|
||||||
|
old_ = previous;
|
||||||
|
existed_ = true;
|
||||||
|
}
|
||||||
|
if (setenv(name, value.c_str(), 1) != 0)
|
||||||
|
throw std::runtime_error("Cannot set test environment");
|
||||||
|
}
|
||||||
|
~ScopedPathEnvironment() {
|
||||||
|
if (existed_)
|
||||||
|
setenv(name_.c_str(), old_.c_str(), 1);
|
||||||
|
else
|
||||||
|
unsetenv(name_.c_str());
|
||||||
|
}
|
||||||
|
|
||||||
|
private:
|
||||||
|
std::string name_, old_;
|
||||||
|
bool existed_ = false;
|
||||||
|
#endif
|
||||||
|
};
|
||||||
void check(bool value, const std::string& message) {
|
void check(bool value, const std::string& message) {
|
||||||
if (!value)
|
if (!value)
|
||||||
throw std::runtime_error(message);
|
throw std::runtime_error(message);
|
||||||
@@ -41,19 +78,23 @@ void text(editor::ProjectLauncher& launcher, const std::string& id, const std::s
|
|||||||
}
|
}
|
||||||
} // namespace
|
} // namespace
|
||||||
int main() {
|
int main() {
|
||||||
const auto root = std::filesystem::temp_directory_path() / ("faset-launcher-ui-" + new_id());
|
const auto root =
|
||||||
|
std::filesystem::temp_directory_path() / path_from_utf8("faset-проекты-" + new_id());
|
||||||
try {
|
try {
|
||||||
const auto existing = root / "Existing project";
|
const auto existing = root / path_from_utf8("Существующий проект");
|
||||||
|
const auto new_project = root / path_from_utf8("Новая игра");
|
||||||
atomic_write_json(existing / "project.faset.json", {{"format", "faset.project"},
|
atomic_write_json(existing / "project.faset.json", {{"format", "faset.project"},
|
||||||
{"version", 1},
|
{"version", 1},
|
||||||
{"name", "Existing project"},
|
{"name", "Existing project"},
|
||||||
{"dimension", 2}});
|
{"dimension", 2}});
|
||||||
const auto recents = root / "recent-projects.json";
|
const auto recents = root / "recent-projects.json";
|
||||||
atomic_write_json(recents, Json::array({existing.string(), (root / "Missing").string(),
|
atomic_write_json(recents,
|
||||||
existing.string()}));
|
Json::array({path_to_utf8(existing), path_to_utf8(root / "Missing"),
|
||||||
|
path_to_utf8(existing)}));
|
||||||
render::Renderer renderer({1100, 720, "Project launcher acceptance", true, true});
|
render::Renderer renderer({1100, 720, "Project launcher acceptance", true, true});
|
||||||
{
|
{
|
||||||
editor::ProjectLauncher launcher(renderer, FASET_TEST_ENGINE, {}, recents);
|
editor::ProjectLauncher launcher(renderer, path_from_utf8(FASET_TEST_ENGINE), {},
|
||||||
|
recents);
|
||||||
launcher.frame({});
|
launcher.frame({});
|
||||||
check(launcher.widgets().find("launcher-create")->selected,
|
check(launcher.widgets().find("launcher-create")->selected,
|
||||||
"No initial path starts Create");
|
"No initial path starts Create");
|
||||||
@@ -61,11 +102,11 @@ int main() {
|
|||||||
!launcher.widgets().find("launcher-recent-1"),
|
!launcher.widgets().find("launcher-recent-1"),
|
||||||
"Recent list includes real valid unique projects only");
|
"Recent list includes real valid unique projects only");
|
||||||
text(launcher, "launcher-name", "Тестовый проект");
|
text(launcher, "launcher-name", "Тестовый проект");
|
||||||
text(launcher, "launcher-path", existing.string());
|
text(launcher, "launcher-path", path_to_utf8(existing));
|
||||||
click(launcher, "launcher-submit");
|
click(launcher, "launcher-submit");
|
||||||
check(!launcher.selection() && !launcher.widgets().find("launcher-error")->text.empty(),
|
check(!launcher.selection() && !launcher.widgets().find("launcher-error")->text.empty(),
|
||||||
"Create must reject nonempty existing project");
|
"Create must reject nonempty existing project");
|
||||||
text(launcher, "launcher-path", (root / "New Game").string());
|
text(launcher, "launcher-path", path_to_utf8(new_project));
|
||||||
click(launcher, "launcher-2d");
|
click(launcher, "launcher-2d");
|
||||||
check(launcher.widgets().find("launcher-2d")->selected, "2D project selection");
|
check(launcher.widgets().find("launcher-2d")->selected, "2D project selection");
|
||||||
renderer.render(launcher.snapshot());
|
renderer.render(launcher.snapshot());
|
||||||
@@ -73,35 +114,36 @@ int main() {
|
|||||||
launcher.frame({key("Return", true)});
|
launcher.frame({key("Return", true)});
|
||||||
check(launcher.selection() && launcher.selection()->create &&
|
check(launcher.selection() && launcher.selection()->create &&
|
||||||
launcher.selection()->dimension == 2 &&
|
launcher.selection()->dimension == 2 &&
|
||||||
launcher.selection()->name == "Тестовый проект",
|
launcher.selection()->name == "Тестовый проект" &&
|
||||||
|
launcher.selection()->path == new_project,
|
||||||
"Create through keyboard returns typed selection");
|
"Create through keyboard returns typed selection");
|
||||||
check(!std::filesystem::exists(root / "New Game"),
|
check(!std::filesystem::exists(new_project),
|
||||||
"Launcher does not create a partial project before Session scaffold");
|
"Launcher does not create a partial project before Session scaffold");
|
||||||
}
|
}
|
||||||
{
|
{
|
||||||
editor::ProjectLauncher launcher(renderer, FASET_TEST_ENGINE, root / "Missing",
|
editor::ProjectLauncher launcher(renderer, path_from_utf8(FASET_TEST_ENGINE),
|
||||||
recents);
|
root / "Missing", recents);
|
||||||
launcher.frame({});
|
launcher.frame({});
|
||||||
click(launcher, "launcher-submit");
|
click(launcher, "launcher-submit");
|
||||||
check(!launcher.selection() && !launcher.widgets().find("launcher-error")->text.empty(),
|
check(!launcher.selection() && !launcher.widgets().find("launcher-error")->text.empty(),
|
||||||
"Open validates missing directory");
|
"Open validates missing directory");
|
||||||
click(launcher, "launcher-recent-0");
|
click(launcher, "launcher-recent-0");
|
||||||
check(launcher.widgets().find("launcher-path")->text == existing.string(),
|
check(launcher.widgets().find("launcher-path")->text == path_to_utf8(existing),
|
||||||
"Recent selection fills actual path");
|
"Recent selection fills actual path");
|
||||||
click(launcher, "launcher-browse");
|
click(launcher, "launcher-browse");
|
||||||
check(launcher.widgets().find("launcher-browser")->visible,
|
check(launcher.widgets().find("launcher-browser")->visible,
|
||||||
"Native retained directory browser opens");
|
"Native retained directory browser opens");
|
||||||
text(launcher, "browser-path", (root / "Absent folder").string());
|
text(launcher, "browser-path", path_to_utf8(root / "Absent folder"));
|
||||||
launcher.frame({key("Return", true)});
|
launcher.frame({key("Return", true)});
|
||||||
check(launcher.widgets().find("launcher-browser")->visible &&
|
check(launcher.widgets().find("launcher-browser")->visible &&
|
||||||
!launcher.widgets().find("browser-choose")->enabled,
|
!launcher.widgets().find("browser-choose")->enabled,
|
||||||
"Invalid typed directory cannot silently select prior directory");
|
"Invalid typed directory cannot silently select prior directory");
|
||||||
text(launcher, "browser-path", existing.string());
|
text(launcher, "browser-path", path_to_utf8(existing));
|
||||||
click(launcher, "browser-up");
|
click(launcher, "browser-up");
|
||||||
check(launcher.widgets().find("browser-path")->text == root.string(),
|
check(launcher.widgets().find("browser-path")->text == path_to_utf8(root),
|
||||||
"Folder browser Up navigation");
|
"Folder browser Up navigation");
|
||||||
click(launcher, "browser-entry-0");
|
click(launcher, "browser-entry-0");
|
||||||
check(launcher.widgets().find("browser-path")->text == existing.string(),
|
check(launcher.widgets().find("browser-path")->text == path_to_utf8(existing),
|
||||||
"Directory list navigation");
|
"Directory list navigation");
|
||||||
renderer.render(launcher.snapshot());
|
renderer.render(launcher.snapshot());
|
||||||
renderer.capture(root / "launcher-browser.ppm");
|
renderer.capture(root / "launcher-browser.ppm");
|
||||||
@@ -114,11 +156,56 @@ int main() {
|
|||||||
launcher.selection()->dimension == 2,
|
launcher.selection()->dimension == 2,
|
||||||
"Open reads project metadata");
|
"Open reads project metadata");
|
||||||
}
|
}
|
||||||
|
{
|
||||||
|
// Exercise the path returned by Create after the caller has created its
|
||||||
|
// project, then reopen it through the launcher's UTF-8 text boundary.
|
||||||
|
atomic_write_json(new_project / "project.faset.json", {{"format", "faset.project"},
|
||||||
|
{"version", 1},
|
||||||
|
{"name", "Тестовый проект"},
|
||||||
|
{"dimension", 2}});
|
||||||
|
editor::ProjectLauncher launcher(renderer, path_from_utf8(FASET_TEST_ENGINE), {},
|
||||||
|
recents);
|
||||||
|
launcher.frame({});
|
||||||
|
click(launcher, "launcher-open");
|
||||||
|
text(launcher, "launcher-path", path_to_utf8(new_project / "project.faset.json"));
|
||||||
|
launcher.frame({key("Return", true)});
|
||||||
|
check(launcher.selection() && launcher.selection()->path == new_project &&
|
||||||
|
launcher.selection()->name == "Тестовый проект",
|
||||||
|
"Unicode project directory survives typed manifest path and reopen");
|
||||||
|
}
|
||||||
|
{
|
||||||
|
const auto home = root / path_from_utf8("Дом пользователя");
|
||||||
|
const auto config = root / path_from_utf8("Настройки пользователя");
|
||||||
|
std::filesystem::create_directories(home);
|
||||||
|
#ifdef _WIN32
|
||||||
|
ScopedPathEnvironment user_home(L"USERPROFILE", home), app_config(L"APPDATA", config);
|
||||||
|
const auto recent_file = config / "Faset/recent-projects.json";
|
||||||
|
#else
|
||||||
|
ScopedPathEnvironment user_home("HOME", home), app_config("XDG_CONFIG_HOME", config);
|
||||||
|
const auto recent_file = config / "faset/recent-projects.json";
|
||||||
|
#endif
|
||||||
|
editor::remember_project(existing);
|
||||||
|
check(read_json(recent_file).at(0) == path_to_utf8(existing),
|
||||||
|
"Unicode config directory and recent project are serialized as UTF-8");
|
||||||
|
editor::ProjectLauncher launcher(renderer, path_from_utf8(FASET_TEST_ENGINE));
|
||||||
|
launcher.frame({});
|
||||||
|
check(launcher.widgets().find("launcher-path")->text ==
|
||||||
|
path_to_utf8(home / "FasetProjects/MyGame"),
|
||||||
|
"Unicode user home forms a native default project path");
|
||||||
|
check(launcher.widgets().find("launcher-recent-0"),
|
||||||
|
"Default recents path reads the Unicode config directory");
|
||||||
|
click(launcher, "launcher-browse");
|
||||||
|
click(launcher, "browser-home");
|
||||||
|
check(launcher.widgets().find("browser-path")->text == path_to_utf8(home),
|
||||||
|
"Home navigation preserves Unicode environment path");
|
||||||
|
launcher.frame({key("Escape")});
|
||||||
|
}
|
||||||
{
|
{
|
||||||
const auto corrupt = root / "Corrupt";
|
const auto corrupt = root / "Corrupt";
|
||||||
atomic_write_json(corrupt / "project.faset.json",
|
atomic_write_json(corrupt / "project.faset.json",
|
||||||
{{"format", "faset.project"}, {"version", 9}, {"name", "Future"}});
|
{{"format", "faset.project"}, {"version", 9}, {"name", "Future"}});
|
||||||
editor::ProjectLauncher launcher(renderer, FASET_TEST_ENGINE, corrupt, recents);
|
editor::ProjectLauncher launcher(renderer, path_from_utf8(FASET_TEST_ENGINE), corrupt,
|
||||||
|
recents);
|
||||||
launcher.frame({});
|
launcher.frame({});
|
||||||
click(launcher, "launcher-submit");
|
click(launcher, "launcher-submit");
|
||||||
check(!launcher.selection() && !launcher.widgets().find("launcher-error")->text.empty(),
|
check(!launcher.selection() && !launcher.widgets().find("launcher-error")->text.empty(),
|
||||||
@@ -129,10 +216,10 @@ int main() {
|
|||||||
check(renderer.stats().validation_errors == 0, "Launcher Vulkan validation");
|
check(renderer.stats().validation_errors == 0, "Launcher Vulkan validation");
|
||||||
std::cout
|
std::cout
|
||||||
<< "Launcher Unicode/create/open/validation/recents/directory browser/keyboard passed. "
|
<< "Launcher Unicode/create/open/validation/recents/directory browser/keyboard passed. "
|
||||||
<< root << '\n';
|
<< path_to_utf8(root) << '\n';
|
||||||
return 0;
|
return 0;
|
||||||
} catch (const std::exception& e) {
|
} catch (const std::exception& e) {
|
||||||
std::cerr << e.what() << "\nRetained: " << root << '\n';
|
std::cerr << e.what() << "\nRetained: " << path_to_utf8(root) << '\n';
|
||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -51,16 +51,17 @@ int main() {
|
|||||||
{"name", "Original project"},
|
{"name", "Original project"},
|
||||||
{"dimension", 3},
|
{"dimension", 3},
|
||||||
{"custom_metadata", {{"preserve", true}}}});
|
{"custom_metadata", {{"preserve", true}}}});
|
||||||
editor::Session session({root, FASET_TEST_ENGINE, root});
|
editor::Session session({root, path_from_utf8(FASET_TEST_ENGINE), root});
|
||||||
render::Renderer renderer({1280, 800, "Project settings acceptance", true, true});
|
render::Renderer renderer({1280, 800, "Project settings acceptance", true, true});
|
||||||
editor::EditorUI ui(session, renderer,
|
editor::EditorUI ui(session, renderer,
|
||||||
std::filesystem::path(FASET_TEST_ENGINE) / "assets/fonts/NotoSans.ttf",
|
path_from_utf8(FASET_TEST_ENGINE) / "assets/fonts/NotoSans.ttf",
|
||||||
std::filesystem::path(FASET_TEST_ENGINE) / "assets/ui/dark.json");
|
path_from_utf8(FASET_TEST_ENGINE) / "assets/ui/dark.json");
|
||||||
ui.frame({});
|
ui.frame({});
|
||||||
click(ui, "add-cube");
|
click(ui, "add-cube");
|
||||||
session.authoring().save(ui.current_document(), "Scenes/Main.scene.json");
|
session.authoring().save(ui.current_document(),
|
||||||
|
path_from_utf8("Scenes/Главная.scene.json"));
|
||||||
const auto other = session.authoring().create("Other", 2);
|
const auto other = session.authoring().create("Other", 2);
|
||||||
session.authoring().save(other.at("id"), "Scenes/Other.scene.json");
|
session.authoring().save(other.at("id"), path_from_utf8("Scenes/Другая.scene.json"));
|
||||||
ui.frame({});
|
ui.frame({});
|
||||||
const auto document_before = session.authoring().query(ui.current_document());
|
const auto document_before = session.authoring().query(ui.current_document());
|
||||||
const auto project_before = read_json(project_file);
|
const auto project_before = read_json(project_file);
|
||||||
@@ -75,12 +76,12 @@ int main() {
|
|||||||
text(ui, "project-settings-name", "Новый проект");
|
text(ui, "project-settings-name", "Новый проект");
|
||||||
click(ui, "project-settings-2d");
|
click(ui, "project-settings-2d");
|
||||||
click(ui, "project-scene-choice-1");
|
click(ui, "project-scene-choice-1");
|
||||||
check(ui.widgets().find("project-settings-start")->text == "Scenes/Other.scene.json",
|
check(ui.widgets().find("project-settings-start")->text == "Scenes/Другая.scene.json",
|
||||||
"Saved scene chooser sets project-relative path");
|
"Saved scene chooser sets project-relative path");
|
||||||
click(ui, "project-settings-save");
|
click(ui, "project-settings-save");
|
||||||
auto saved = read_json(project_file);
|
auto saved = read_json(project_file);
|
||||||
check(saved["name"] == "Новый проект" && saved["dimension"] == 2 &&
|
check(saved["name"] == "Новый проект" && saved["dimension"] == 2 &&
|
||||||
saved["start_scene"] == "Scenes/Other.scene.json",
|
saved["start_scene"] == "Scenes/Другая.scene.json",
|
||||||
"Explicit Save project persists typed settings");
|
"Explicit Save project persists typed settings");
|
||||||
check(saved["custom_metadata"] == project_before["custom_metadata"] &&
|
check(saved["custom_metadata"] == project_before["custom_metadata"] &&
|
||||||
saved["id"] == project_before["id"],
|
saved["id"] == project_before["id"],
|
||||||
@@ -113,7 +114,7 @@ int main() {
|
|||||||
click(ui, "project-settings-3d");
|
click(ui, "project-settings-3d");
|
||||||
click(ui, "project-scene-choice-0");
|
click(ui, "project-scene-choice-0");
|
||||||
click(ui, "project-settings-save");
|
click(ui, "project-settings-save");
|
||||||
check(read_json(project_file)["start_scene"] == "Scenes/Main.scene.json",
|
check(read_json(project_file)["start_scene"] == "Scenes/Главная.scene.json",
|
||||||
"Save after explicit reload uses new content revision");
|
"Save after explicit reload uses new content revision");
|
||||||
check(session.authoring().query(ui.current_document()) == document_before,
|
check(session.authoring().query(ui.current_document()) == document_before,
|
||||||
"Project settings remain outside scene Undo throughout conflicts");
|
"Project settings remain outside scene Undo throughout conflicts");
|
||||||
@@ -126,10 +127,10 @@ int main() {
|
|||||||
check(renderer.stats().validation_errors == 0, "Project settings Vulkan validation");
|
check(renderer.stats().validation_errors == 0, "Project settings Vulkan validation");
|
||||||
std::cout << "Project settings name/type/start-scene Save/Cancel/Reload/conflict, metadata "
|
std::cout << "Project settings name/type/start-scene Save/Cancel/Reload/conflict, metadata "
|
||||||
"preservation and separate scene Undo passed. "
|
"preservation and separate scene Undo passed. "
|
||||||
<< root << '\n';
|
<< path_to_utf8(root) << '\n';
|
||||||
return 0;
|
return 0;
|
||||||
} catch (const std::exception& e) {
|
} catch (const std::exception& e) {
|
||||||
std::cerr << e.what() << "\nRetained: " << root << '\n';
|
std::cerr << e.what() << "\nRetained: " << path_to_utf8(root) << '\n';
|
||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,15 +39,14 @@ int main() {
|
|||||||
try {
|
try {
|
||||||
const auto styles = root / "styles/dark.json",
|
const auto styles = root / "styles/dark.json",
|
||||||
layout_path = root / "styles/editor-layout.json";
|
layout_path = root / "styles/editor-layout.json";
|
||||||
auto theme = read_json(std::filesystem::path(FASET_TEST_ENGINE) / "assets/ui/dark.json");
|
auto theme = read_json(path_from_utf8(FASET_TEST_ENGINE) / "assets/ui/dark.json");
|
||||||
auto layout =
|
auto layout = read_json(path_from_utf8(FASET_TEST_ENGINE) / "assets/ui/editor-layout.json");
|
||||||
read_json(std::filesystem::path(FASET_TEST_ENGINE) / "assets/ui/editor-layout.json");
|
|
||||||
atomic_write_json(styles, theme);
|
atomic_write_json(styles, theme);
|
||||||
atomic_write_json(layout_path, layout);
|
atomic_write_json(layout_path, layout);
|
||||||
editor::Session session({root / "project", FASET_TEST_ENGINE, root});
|
editor::Session session({root / "project", path_from_utf8(FASET_TEST_ENGINE), root});
|
||||||
render::Renderer renderer({1280, 800, "Style reload acceptance", true, true});
|
render::Renderer renderer({1280, 800, "Style reload acceptance", true, true});
|
||||||
editor::EditorUI ui(session, renderer,
|
editor::EditorUI ui(session, renderer,
|
||||||
std::filesystem::path(FASET_TEST_ENGINE) / "assets/fonts/NotoSans.ttf",
|
path_from_utf8(FASET_TEST_ENGINE) / "assets/fonts/NotoSans.ttf",
|
||||||
styles);
|
styles);
|
||||||
ui.frame({});
|
ui.frame({});
|
||||||
click(ui, "add-cube");
|
click(ui, "add-cube");
|
||||||
@@ -118,10 +117,10 @@ int main() {
|
|||||||
check(renderer.stats().validation_errors == 0, "Reload Vulkan validation");
|
check(renderer.stats().validation_errors == 0, "Reload Vulkan validation");
|
||||||
std::cout << "Theme/layout live reload pixels+geometry, atomic rejection, focus+draft "
|
std::cout << "Theme/layout live reload pixels+geometry, atomic rejection, focus+draft "
|
||||||
"preservation, callback and diagnostic dedup passed. "
|
"preservation, callback and diagnostic dedup passed. "
|
||||||
<< root << '\n';
|
<< path_to_utf8(root) << '\n';
|
||||||
return 0;
|
return 0;
|
||||||
} catch (const std::exception& e) {
|
} catch (const std::exception& e) {
|
||||||
std::cerr << e.what() << "\nRetained: " << root << '\n';
|
std::cerr << e.what() << "\nRetained: " << path_to_utf8(root) << '\n';
|
||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -73,20 +73,20 @@ float position(const Json& object) {
|
|||||||
int main() {
|
int main() {
|
||||||
const auto root = std::filesystem::temp_directory_path() / ("faset-template-ui-" + new_id());
|
const auto root = std::filesystem::temp_directory_path() / ("faset-template-ui-" + new_id());
|
||||||
try {
|
try {
|
||||||
editor::Session session({root, FASET_TEST_ENGINE, root});
|
editor::Session session({root, path_from_utf8(FASET_TEST_ENGINE), root});
|
||||||
render::Renderer renderer({1280, 900, "Template workflow test", true, true});
|
render::Renderer renderer({1280, 900, "Template workflow test", true, true});
|
||||||
editor::EditorUI ui(session, renderer,
|
editor::EditorUI ui(session, renderer,
|
||||||
std::filesystem::path(FASET_TEST_ENGINE) / "assets/fonts/NotoSans.ttf",
|
path_from_utf8(FASET_TEST_ENGINE) / "assets/fonts/NotoSans.ttf",
|
||||||
std::filesystem::path(FASET_TEST_ENGINE) / "assets/ui/dark.json");
|
path_from_utf8(FASET_TEST_ENGINE) / "assets/ui/dark.json");
|
||||||
ui.frame({});
|
ui.frame({});
|
||||||
const auto main = ui.current_document();
|
const auto main = ui.current_document();
|
||||||
click(ui, "add-cube");
|
click(ui, "add-cube");
|
||||||
text(ui, "object-name", "Door");
|
text(ui, "object-name", "Door");
|
||||||
const auto local = ui.selected_entity();
|
const auto local = ui.selected_entity();
|
||||||
click(ui, "menu-Scene");
|
click(ui, "menu-Scene");
|
||||||
text(ui, "template-path", "Assets/Templates/Door.scene.json");
|
text(ui, "template-path", "Assets/Templates/Дверь.scene.json");
|
||||||
click(ui, "save-template");
|
click(ui, "save-template");
|
||||||
check(std::filesystem::exists(root / "Assets/Templates/Door.scene.json"),
|
check(std::filesystem::exists(root / path_from_utf8("Assets/Templates/Дверь.scene.json")),
|
||||||
"Manual source template creation");
|
"Manual source template creation");
|
||||||
for (int i = 0; i < 2; ++i) {
|
for (int i = 0; i < 2; ++i) {
|
||||||
click(ui, "menu-Scene");
|
click(ui, "menu-Scene");
|
||||||
@@ -105,12 +105,12 @@ int main() {
|
|||||||
click(ui, "menu-Scene");
|
click(ui, "menu-Scene");
|
||||||
click(ui, "instance-template");
|
click(ui, "instance-template");
|
||||||
click(ui, "menu-File");
|
click(ui, "menu-File");
|
||||||
text(ui, "save-path", "Assets/Templates/Outer.scene.json");
|
text(ui, "save-path", "Assets/Templates/Внешняя.scene.json");
|
||||||
click(ui, "save-as-button");
|
click(ui, "save-as-button");
|
||||||
click(ui, "back-document");
|
click(ui, "back-document");
|
||||||
check(ui.current_document() == main, "Open source/back document navigation");
|
check(ui.current_document() == main, "Open source/back document navigation");
|
||||||
click(ui, "menu-Scene");
|
click(ui, "menu-Scene");
|
||||||
text(ui, "template-path", "Assets/Templates/Outer.scene.json");
|
text(ui, "template-path", "Assets/Templates/Внешняя.scene.json");
|
||||||
click(ui, "instance-template");
|
click(ui, "instance-template");
|
||||||
auto nested = leaf(session, main, 2);
|
auto nested = leaf(session, main, 2);
|
||||||
const auto nested_id = nested.at("id").get<std::string>();
|
const auto nested_id = nested.at("id").get<std::string>();
|
||||||
@@ -178,6 +178,35 @@ int main() {
|
|||||||
check(query(session, ui)["scene"]["simulation"]["gravity"][1] == -9.81,
|
check(query(session, ui)["scene"]["simulation"]["gravity"][1] == -9.81,
|
||||||
"Simulation Undo restores gravity");
|
"Simulation Undo restores gravity");
|
||||||
|
|
||||||
|
// Save through the same controls a user sees, then open a new editor
|
||||||
|
// session: an in-memory resolver alone cannot prove persistent identity.
|
||||||
|
click(ui, "entity-" + nested_id);
|
||||||
|
click(ui, "object-open-source");
|
||||||
|
click(ui, "save");
|
||||||
|
click(ui, "back-document");
|
||||||
|
click(ui, "menu-File");
|
||||||
|
text(ui, "save-path", "Scenes/Экземпляры.scene.json");
|
||||||
|
click(ui, "save-as-button");
|
||||||
|
const auto saved_scene = query(session, ui).at("scene");
|
||||||
|
const auto saved_resolved = session.commands().resolved_scene(main);
|
||||||
|
{
|
||||||
|
editor::Session reopened({root, path_from_utf8(FASET_TEST_ENGINE), root});
|
||||||
|
editor::EditorUI reopened_ui(
|
||||||
|
reopened, renderer, path_from_utf8(FASET_TEST_ENGINE) / "assets/fonts/NotoSans.ttf",
|
||||||
|
path_from_utf8(FASET_TEST_ENGINE) / "assets/ui/dark.json");
|
||||||
|
reopened_ui.frame({});
|
||||||
|
click(reopened_ui, "menu-File");
|
||||||
|
text(reopened_ui, "open-path", "Scenes/Экземпляры.scene.json");
|
||||||
|
click(reopened_ui, "open-path-button");
|
||||||
|
check(query(reopened, reopened_ui).at("scene") == saved_scene,
|
||||||
|
"Save and reopen preserve instance IDs, overrides and local additions");
|
||||||
|
check(reopened.commands().resolved_scene(reopened_ui.current_document()) ==
|
||||||
|
saved_resolved,
|
||||||
|
"New session resolves saved nested source edits with identical provenance");
|
||||||
|
check(leaf(reopened, reopened_ui.current_document(), 2).at("id") == nested_id,
|
||||||
|
"Resolved nested object identity survives reopening");
|
||||||
|
}
|
||||||
|
|
||||||
nested = leaf(session, main, 2);
|
nested = leaf(session, main, 2);
|
||||||
click(ui, "entity-" + nested_id);
|
click(ui, "entity-" + nested_id);
|
||||||
text(ui, position_field(nested), "7");
|
text(ui, position_field(nested), "7");
|
||||||
@@ -218,10 +247,10 @@ int main() {
|
|||||||
std::cout << "Manual templates: create/two instances/nesting/overrides/Revert/source "
|
std::cout << "Manual templates: create/two instances/nesting/overrides/Revert/source "
|
||||||
"rename/suppression restore/local additions/conflicts/opaque versions; "
|
"rename/suppression restore/local additions/conflicts/opaque versions; "
|
||||||
"simulation UI+Undo passed. "
|
"simulation UI+Undo passed. "
|
||||||
<< root << '\n';
|
<< path_to_utf8(root) << '\n';
|
||||||
return 0;
|
return 0;
|
||||||
} catch (const std::exception& error) {
|
} catch (const std::exception& error) {
|
||||||
std::cerr << error.what() << "\nRetained: " << root << '\n';
|
std::cerr << error.what() << "\nRetained: " << path_to_utf8(root) << '\n';
|
||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,7 +44,8 @@ void text(editor::EditorUI& ui, const std::string& id, const std::string& value,
|
|||||||
ui.frame(events);
|
ui.frame(events);
|
||||||
}
|
}
|
||||||
int main() {
|
int main() {
|
||||||
auto root = std::filesystem::temp_directory_path() / ("faset-ui-authoring-" + new_id());
|
auto root =
|
||||||
|
std::filesystem::temp_directory_path() / path_from_utf8("faset-ui-проект-" + new_id());
|
||||||
try {
|
try {
|
||||||
std::filesystem::create_directories(root);
|
std::filesystem::create_directories(root);
|
||||||
atomic_write_json(root / "project.faset.json", {{"format", "faset.project"},
|
atomic_write_json(root / "project.faset.json", {{"format", "faset.project"},
|
||||||
@@ -53,15 +54,16 @@ int main() {
|
|||||||
{"dimension", 3}});
|
{"dimension", 3}});
|
||||||
#if defined(FASET_TEST_PLUGIN_DIRECTORY)
|
#if defined(FASET_TEST_PLUGIN_DIRECTORY)
|
||||||
std::filesystem::create_directories(root / "Plugins");
|
std::filesystem::create_directories(root / "Plugins");
|
||||||
for (const auto& file : std::filesystem::directory_iterator(FASET_TEST_PLUGIN_DIRECTORY))
|
for (const auto& file :
|
||||||
|
std::filesystem::directory_iterator(path_from_utf8(FASET_TEST_PLUGIN_DIRECTORY)))
|
||||||
if (file.is_regular_file())
|
if (file.is_regular_file())
|
||||||
std::filesystem::copy_file(file.path(), root / "Plugins" / file.path().filename());
|
std::filesystem::copy_file(file.path(), root / "Plugins" / file.path().filename());
|
||||||
#endif
|
#endif
|
||||||
editor::Session session({root, FASET_TEST_ENGINE, root});
|
editor::Session session({root, path_from_utf8(FASET_TEST_ENGINE), root});
|
||||||
render::Renderer renderer({1280, 800, "Faset editor test", true, true});
|
render::Renderer renderer({1280, 800, "Faset editor test", true, true});
|
||||||
editor::EditorUI ui(session, renderer,
|
editor::EditorUI ui(session, renderer,
|
||||||
std::filesystem::path(FASET_TEST_ENGINE) / "assets/fonts/NotoSans.ttf",
|
path_from_utf8(FASET_TEST_ENGINE) / "assets/fonts/NotoSans.ttf",
|
||||||
std::filesystem::path(FASET_TEST_ENGINE) / "assets/ui/dark.json");
|
path_from_utf8(FASET_TEST_ENGINE) / "assets/ui/dark.json");
|
||||||
ui.frame({});
|
ui.frame({});
|
||||||
click(ui, "add-cube");
|
click(ui, "add-cube");
|
||||||
auto state = session.authoring().query(ui.current_document());
|
auto state = session.authoring().query(ui.current_document());
|
||||||
@@ -179,7 +181,7 @@ int main() {
|
|||||||
click(ui, "tab-assets");
|
click(ui, "tab-assets");
|
||||||
#endif
|
#endif
|
||||||
std::filesystem::create_directories(root / "Assets");
|
std::filesystem::create_directories(root / "Assets");
|
||||||
const auto image_path = root / "Assets/TwoPixels.png";
|
const auto image_path = root / path_from_utf8("Assets/Два пикселя.png");
|
||||||
{
|
{
|
||||||
std::ofstream stream(image_path, std::ios::binary);
|
std::ofstream stream(image_path, std::ios::binary);
|
||||||
const auto& png = faset::test_images::png_red_green;
|
const auto& png = faset::test_images::png_red_green;
|
||||||
@@ -188,8 +190,8 @@ int main() {
|
|||||||
}
|
}
|
||||||
const auto import_job =
|
const auto import_job =
|
||||||
session.commands()
|
session.commands()
|
||||||
.call("faset_import",
|
.call("faset_import", {{"path", "Assets/Два пикселя.png"},
|
||||||
{{"path", "Assets/TwoPixels.png"}, {"settings", {{"pixels_per_unit", 1.0}}}})
|
{"settings", {{"pixels_per_unit", 1.0}}}})
|
||||||
.at("job")
|
.at("job")
|
||||||
.get<std::string>();
|
.get<std::string>();
|
||||||
Json imported;
|
Json imported;
|
||||||
@@ -206,6 +208,8 @@ int main() {
|
|||||||
const auto image_id = imported.at("result").at("asset_id").get<std::string>();
|
const auto image_id = imported.at("result").at("asset_id").get<std::string>();
|
||||||
auto* image_row = ui.widgets().find("asset-" + image_id);
|
auto* image_row = ui.widgets().find("asset-" + image_id);
|
||||||
check(image_row, "Imported image in asset browser");
|
check(image_row, "Imported image in asset browser");
|
||||||
|
check(image_row->text.find("Два пикселя") != std::string::npos,
|
||||||
|
"Asset browser displays Unicode source filename");
|
||||||
const auto asset_rect = image_row->rect.intersection(image_row->clip);
|
const auto asset_rect = image_row->rect.intersection(image_row->clip);
|
||||||
const auto viewport_rect = ui.widgets().find("viewport")->rect;
|
const auto viewport_rect = ui.widgets().find("viewport")->rect;
|
||||||
down.x = asset_rect.x + 100;
|
down.x = asset_rect.x + 100;
|
||||||
@@ -253,7 +257,7 @@ int main() {
|
|||||||
std::cout << "Editor UI: actual events create/select/rename/typed "
|
std::cout << "Editor UI: actual events create/select/rename/typed "
|
||||||
"fields/Undo/Redo/conflict/one drag transaction passed. "
|
"fields/Undo/Redo/conflict/one drag transaction passed. "
|
||||||
"Screenshot: "
|
"Screenshot: "
|
||||||
<< (root / "editor-ui.ppm") << '\n';
|
<< path_to_utf8(root / "editor-ui.ppm") << '\n';
|
||||||
return 0;
|
return 0;
|
||||||
} catch (const std::exception& e) {
|
} catch (const std::exception& e) {
|
||||||
std::cerr << e.what() << '\n';
|
std::cerr << e.what() << '\n';
|
||||||
|
|||||||
+13
-7
@@ -44,8 +44,8 @@ def run(executable, project):
|
|||||||
names = {tool["name"] for tool in request("tools/list")["result"]["tools"]}
|
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_scene_edit", "faset_export", "faset_job_cancel", "faset_plugins"} <= names
|
||||||
assert "faset_runtime_query" not in names and "faset_editor_capture" not in names
|
assert "faset_runtime_query" not in names and "faset_editor_capture" not in names
|
||||||
if (project / "Scenes/main.scene.json").exists():
|
if (project / "Scenes/Начало Café 世界.scene.json").exists():
|
||||||
opened = call("faset_document_open", {"path": "Scenes/main.scene.json"})
|
opened = call("faset_document_open", {"path": "Scenes/Начало Café 世界.scene.json"})
|
||||||
assert opened["scene"]["entities"][0]["name"] == "Door 世界"
|
assert opened["scene"]["entities"][0]["name"] == "Door 世界"
|
||||||
return
|
return
|
||||||
scene = call("faset_document_create", {"name": "MCP integration", "dimension": 3})
|
scene = call("faset_document_create", {"name": "MCP integration", "dimension": 3})
|
||||||
@@ -62,7 +62,7 @@ def run(executable, project):
|
|||||||
assert undone["scene"]["entities"] == []
|
assert undone["scene"]["entities"] == []
|
||||||
redone = call("faset_redo", {"document": identity, "revision": 2})
|
redone = call("faset_redo", {"document": identity, "revision": 2})
|
||||||
assert redone["scene"] == changed["scene"]
|
assert redone["scene"] == changed["scene"]
|
||||||
saved = call("faset_document_save", {"document": identity, "path": "Scenes/main.scene.json"})
|
saved = call("faset_document_save", {"document": identity, "path": "Scenes/Начало Café 世界.scene.json"})
|
||||||
assert not saved["dirty"]
|
assert not saved["dirty"]
|
||||||
call("faset_document_open", {"path": "../outside.json"}, True)
|
call("faset_document_open", {"path": "../outside.json"}, True)
|
||||||
assert request("resources/read", {"uri": "faset://documents"})["result"]["contents"]
|
assert request("resources/read", {"uri": "faset://documents"})["result"]["contents"]
|
||||||
@@ -109,8 +109,14 @@ def eof_tail(executable, project):
|
|||||||
|
|
||||||
|
|
||||||
with tempfile.TemporaryDirectory(prefix="faset-mcp-stdio-") as temporary:
|
with tempfile.TemporaryDirectory(prefix="faset-mcp-stdio-") as temporary:
|
||||||
run(sys.argv[1], pathlib.Path(temporary))
|
project = pathlib.Path(temporary) / "Faset Café 世界"
|
||||||
run(sys.argv[1], pathlib.Path(temporary))
|
created = subprocess.run([sys.argv[1], "--project", str(project), "--new", "Проект Café 世界",
|
||||||
disconnected_output(sys.argv[1], pathlib.Path(temporary))
|
"--command", json.dumps({"name": "faset_project", "arguments": {}}, ensure_ascii=False)],
|
||||||
eof_tail(sys.argv[1], pathlib.Path(temporary))
|
capture_output=True, text=True, encoding="utf-8", timeout=20)
|
||||||
|
assert created.returncode == 0, created.stderr
|
||||||
|
assert json.loads((project / "project.faset.json").read_text(encoding="utf-8"))["name"] == "Проект Café 世界"
|
||||||
|
run(sys.argv[1], project)
|
||||||
|
run(sys.argv[1], project)
|
||||||
|
disconnected_output(sys.argv[1], project)
|
||||||
|
eof_tail(sys.argv[1], project)
|
||||||
print("Real MCP stdio lifecycle, clean stdout, revision conflict, retry, Undo/Redo, disk reopen, broken output pipe and EOF shutdown passed")
|
print("Real MCP stdio lifecycle, clean stdout, revision conflict, retry, Undo/Redo, disk reopen, broken output pipe and EOF shutdown passed")
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
#include "Gameplay.hpp"
|
||||||
|
#include <stdexcept>
|
||||||
|
|
||||||
|
namespace faset::gameplay {
|
||||||
|
void registerGameplay(runtime::Runtime& runtime) {
|
||||||
|
runtime::Behavior behavior;
|
||||||
|
behavior.onStart = [](auto&, auto, double) { throw std::runtime_error("TEST_ON_START"); };
|
||||||
|
behavior.update = [](auto&, auto, double) { throw std::runtime_error("TEST_UPDATE"); };
|
||||||
|
behavior.onDestroy = [](auto&, auto, double) { throw std::runtime_error("TEST_ON_DESTROY"); };
|
||||||
|
runtime.registerBehavior("test.diagnostics", std::move(behavior));
|
||||||
|
}
|
||||||
|
nlohmann::json schema() {
|
||||||
|
return nlohmann::json::array(
|
||||||
|
{{{"id", "test.diagnostics"}, {"version", 2}, {"fields", nlohmann::json::object()}}});
|
||||||
|
}
|
||||||
|
} // namespace faset::gameplay
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
#pragma once
|
||||||
|
#include <faset/runtime/Runtime.hpp>
|
||||||
|
|
||||||
|
namespace faset::gameplay {
|
||||||
|
void registerGameplay(runtime::Runtime& runtime);
|
||||||
|
nlohmann::json schema();
|
||||||
|
} // namespace faset::gameplay
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
"""Normal Player shutdown reports lifecycle failures without losing completed stats."""
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory(prefix="faset-player-diagnostics-") as temporary:
|
||||||
|
root = Path(temporary) / "Faset Café 世界"
|
||||||
|
root.mkdir()
|
||||||
|
scene = root / "Сцена.scene.json"
|
||||||
|
profile = root / "Профиль.json"
|
||||||
|
scene.write_text(json.dumps({
|
||||||
|
"format": "faset.scene", "version": 1, "id": "diagnostics-scene",
|
||||||
|
"name": "Lifecycle diagnostics", "dimension": 2, "instances": [],
|
||||||
|
"entities": [{"id": "diagnostics-object", "name": "Throwing behavior", "parent": None,
|
||||||
|
"components": [{"id": "diagnostics-component", "type": "test.diagnostics",
|
||||||
|
"version": 2, "fields": {}}]}]
|
||||||
|
}), encoding="utf-8")
|
||||||
|
result = subprocess.run([sys.argv[1], "--scene", str(scene), "--headless", "--frames", "1",
|
||||||
|
"--profile", str(profile)],
|
||||||
|
capture_output=True, text=True, encoding="utf-8", timeout=30)
|
||||||
|
assert result.returncode == 0, (result.stdout, result.stderr)
|
||||||
|
for phase in ["TEST_ON_START", "TEST_UPDATE", "TEST_ON_DESTROY"]:
|
||||||
|
assert result.stderr.count(phase) == 1, (phase, result.stderr)
|
||||||
|
summary = json.loads(result.stdout)
|
||||||
|
assert summary["frames"] == 1 and summary["ticks"] == 1, summary
|
||||||
|
report = json.loads(profile.read_text(encoding="utf-8"))
|
||||||
|
assert report["completed_frames"] == 1 and len(report["samples"]) == 1, report
|
||||||
|
assert report["samples"][0]["tick"] == 1, report["samples"]
|
||||||
|
|
||||||
|
# The same linked v2 schema must validate without registering or invoking behavior.
|
||||||
|
validated = subprocess.run([sys.argv[1], "--scene", str(scene), "--validate"],
|
||||||
|
capture_output=True, text=True, encoding="utf-8", timeout=20)
|
||||||
|
assert validated.returncode == 0, validated.stderr
|
||||||
|
assert "TEST_" not in validated.stderr, validated.stderr
|
||||||
|
assert json.loads(validated.stdout)["validated"]
|
||||||
|
print("Player normal shutdown diagnostics, preserved tick/profile stats and callback-free validation passed")
|
||||||
@@ -14,11 +14,11 @@ void require(bool value, const char* message) {
|
|||||||
throw std::runtime_error(message);
|
throw std::runtime_error(message);
|
||||||
}
|
}
|
||||||
int compile(const fs::path& source, const fs::path& output) {
|
int compile(const fs::path& source, const fs::path& output) {
|
||||||
Process process(
|
Process process({{FASET_PYTHON_EXECUTABLE, FASET_SHADER_COMPILE_TOOL, "--compiler",
|
||||||
{{FASET_PYTHON_EXECUTABLE, FASET_SHADER_COMPILE_TOOL, "--compiler", FASET_TEST_SLANGC,
|
FASET_TEST_SLANGC, "--source", path_to_utf8(source), "--entry",
|
||||||
"--source", source.string(), "--entry", "fragmentMain", "--output", output.string()},
|
"fragmentMain", "--output", path_to_utf8(output)},
|
||||||
{},
|
{},
|
||||||
{}});
|
{}});
|
||||||
std::string diagnostics;
|
std::string diagnostics;
|
||||||
while (true) {
|
while (true) {
|
||||||
auto result = process.poll();
|
auto result = process.poll();
|
||||||
@@ -34,12 +34,13 @@ int compile(const fs::path& source, const fs::path& output) {
|
|||||||
}
|
}
|
||||||
} // namespace
|
} // namespace
|
||||||
int main() {
|
int main() {
|
||||||
const auto temporary = fs::temp_directory_path() / ("faset-shader-reload-" + new_id());
|
const auto temporary =
|
||||||
|
fs::temp_directory_path() / path_from_utf8("Faset shader Café 世界 " + new_id());
|
||||||
struct Cleanup {
|
struct Cleanup {
|
||||||
fs::path path;
|
fs::path path;
|
||||||
~Cleanup() {
|
~Cleanup() {
|
||||||
std::error_code ignored;
|
std::error_code ignored;
|
||||||
fs::remove_all(path, ignored);
|
fs::remove_all(native_io_path(path), ignored);
|
||||||
}
|
}
|
||||||
} cleanup{temporary};
|
} cleanup{temporary};
|
||||||
try {
|
try {
|
||||||
@@ -48,10 +49,10 @@ int main() {
|
|||||||
for (const auto* entry : {"vertexMain", "fragmentMain", "shadowMain"})
|
for (const auto* entry : {"vertexMain", "fragmentMain", "shadowMain"})
|
||||||
for (const auto* extension : {".spv", ".reflection.json"}) {
|
for (const auto* extension : {".spv", ".reflection.json"}) {
|
||||||
const auto name = std::string(entry) + extension;
|
const auto name = std::string(entry) + extension;
|
||||||
fs::copy_file(fs::path(FASET_TEST_SHADER_DIRECTORY) / name, bundle / name);
|
fs::copy_file(path_from_utf8(FASET_TEST_SHADER_DIRECTORY) / name, bundle / name);
|
||||||
}
|
}
|
||||||
const auto source = temporary / "reload.slang";
|
const auto source = temporary / "reload.slang";
|
||||||
const auto original_source = read_text(FASET_TEST_SHADER_SOURCE);
|
const auto original_source = read_text(path_from_utf8(FASET_TEST_SHADER_SOURCE));
|
||||||
const auto original_spirv = read_text(bundle / "fragmentMain.spv");
|
const auto original_spirv = read_text(bundle / "fragmentMain.spv");
|
||||||
const auto original_reflection = read_text(bundle / "fragmentMain.reflection.json");
|
const auto original_reflection = read_text(bundle / "fragmentMain.reflection.json");
|
||||||
const auto original_fingerprint = Json::parse(original_reflection).at("layout_fingerprint");
|
const auto original_fingerprint = Json::parse(original_reflection).at("layout_fingerprint");
|
||||||
@@ -63,9 +64,39 @@ int main() {
|
|||||||
configuration.shader_directory = bundle;
|
configuration.shader_directory = bundle;
|
||||||
render::Renderer renderer(configuration);
|
render::Renderer renderer(configuration);
|
||||||
render::Snapshot scene;
|
render::Snapshot scene;
|
||||||
scene.ui_quads.push_back({0, 0, 64, 64, {1, .8f, .4f, 1}});
|
scene.ui_quads.push_back({0, 0, 32, 64, {1, .8f, .4f, 1}});
|
||||||
|
scene.sprites.push_back({{.5f, 0, .5f}, {1, 2}, {.2f, 1, .4f, 1}});
|
||||||
renderer.render(scene);
|
renderer.render(scene);
|
||||||
auto expected = renderer.pixels();
|
auto expected = renderer.pixels();
|
||||||
|
// Cooked/exported generations can exceed MAX_PATH even for modest project
|
||||||
|
// names. Exercise the renderer's file boundaries without relying on the
|
||||||
|
// external shader compiler's own long-path policy.
|
||||||
|
const auto deep_bundle = temporary / std::string(96, 'a') / std::string(96, 'b') /
|
||||||
|
std::string(96, 'c') / "shaders";
|
||||||
|
require(deep_bundle.native().size() > 300,
|
||||||
|
"Shader file fixture must exceed the legacy Windows path limit");
|
||||||
|
fs::create_directories(native_io_path(deep_bundle));
|
||||||
|
for (const auto* entry : {"vertexMain", "fragmentMain", "shadowMain"})
|
||||||
|
for (const auto* extension : {".spv", ".reflection.json"}) {
|
||||||
|
const auto name = std::string(entry) + extension;
|
||||||
|
atomic_write(deep_bundle / name, read_text(bundle / name));
|
||||||
|
}
|
||||||
|
render::validate_shader_bundle(deep_bundle);
|
||||||
|
{
|
||||||
|
auto deep_configuration = configuration;
|
||||||
|
deep_configuration.shader_directory = deep_bundle;
|
||||||
|
render::Renderer deep_renderer(deep_configuration);
|
||||||
|
deep_renderer.render(scene);
|
||||||
|
require(deep_renderer.pixels() == expected,
|
||||||
|
"Deep Unicode shader bundle must render the same pixels");
|
||||||
|
const auto capture = deep_bundle / path_from_utf8("Capture Café 世界.ppm");
|
||||||
|
deep_renderer.capture(capture);
|
||||||
|
const auto bytes = read_text(capture);
|
||||||
|
require(bytes.starts_with("P6\n64 64\n255\n") && bytes.size() == 13 + 64 * 64 * 3,
|
||||||
|
"Deep Unicode capture must contain the complete rendered image");
|
||||||
|
require(deep_renderer.stats().validation_errors == 0,
|
||||||
|
"Deep shader bundle has no Vulkan validation errors");
|
||||||
|
}
|
||||||
require(renderer.stats().gpu_allocated_bytes > 1024 * 1024 &&
|
require(renderer.stats().gpu_allocated_bytes > 1024 * 1024 &&
|
||||||
renderer.stats().texture_count >= 1,
|
renderer.stats().texture_count >= 1,
|
||||||
"Frame profile reports live Vulkan allocations and textures");
|
"Frame profile reports live Vulkan allocations and textures");
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
#include <cmath>
|
||||||
|
#include <faset/render/renderer.hpp>
|
||||||
|
#include <iostream>
|
||||||
|
#include <stdexcept>
|
||||||
|
#include <utility>
|
||||||
|
|
||||||
|
using namespace faset::render;
|
||||||
|
namespace {
|
||||||
|
void require(bool condition, const char* message) {
|
||||||
|
if (!condition)
|
||||||
|
throw std::runtime_error(message);
|
||||||
|
}
|
||||||
|
} // namespace
|
||||||
|
int main() {
|
||||||
|
try {
|
||||||
|
Renderer renderer({64, 64, "Faset sprite alpha validation", true, true});
|
||||||
|
Snapshot scene;
|
||||||
|
scene.clear_color = {0, 0, 0, 1};
|
||||||
|
Sprite front{{0, 0, .2f}, {2, 2}, {0, 1, 0, 0}};
|
||||||
|
Sprite back{{0, 0, .8f}, {2, 2}, {1, 0, 0, 1}};
|
||||||
|
auto color = [&](int red, int green, const char* message) {
|
||||||
|
renderer.render(scene);
|
||||||
|
const auto pixels = renderer.pixels();
|
||||||
|
const auto index = (32 * 64 + 32) * 4;
|
||||||
|
require(std::abs(int(pixels[index]) - red) <= 2 &&
|
||||||
|
std::abs(int(pixels[index + 1]) - green) <= 2 && pixels[index + 2] <= 2,
|
||||||
|
message);
|
||||||
|
require(renderer.stats().validation_errors == 0, "Sprite Vulkan validation error");
|
||||||
|
};
|
||||||
|
scene.sprites = {front, back};
|
||||||
|
color(255, 0, "Fully transparent front sprite must not occlude a later opaque sprite");
|
||||||
|
front.color[3] = .5f;
|
||||||
|
scene.sprites = {front, back};
|
||||||
|
color(128, 128, "Partial alpha must composite over the farther sprite");
|
||||||
|
scene.sprites = {back, front};
|
||||||
|
color(128, 128, "Unequal-depth compositing must be independent of submission order");
|
||||||
|
front.position[2] = .9f;
|
||||||
|
front.layer = 5;
|
||||||
|
scene.sprites = {front, back};
|
||||||
|
color(128, 128, "Explicit higher layer must take priority over sprite depth");
|
||||||
|
front.position[2] = back.position[2];
|
||||||
|
front.layer = back.layer;
|
||||||
|
scene.sprites = {front, back};
|
||||||
|
color(255, 0, "Equal-layer/equal-depth sprites must preserve submission order");
|
||||||
|
scene.sprites = {back, front};
|
||||||
|
color(128, 128, "Later equal-depth sprite must composite on top");
|
||||||
|
auto texture = std::make_shared<Texture>();
|
||||||
|
texture->width = texture->height = 1;
|
||||||
|
texture->rgba = {0, 255, 0, 0};
|
||||||
|
front.color = {1, 1, 1, 1};
|
||||||
|
front.texture = texture;
|
||||||
|
front.position[2] = .2f;
|
||||||
|
scene.sprites = {front, back};
|
||||||
|
color(255, 0, "Transparent texture pixels must preserve underlying sprites");
|
||||||
|
texture->rgba[3] = 128;
|
||||||
|
++texture->revision;
|
||||||
|
color(127, 128, "Texture alpha must blend with the already rendered background");
|
||||||
|
auto opaque = std::make_shared<Mesh>();
|
||||||
|
for (const auto point : {Vec3{-1, -1, .4f}, Vec3{1, -1, .4f}, Vec3{1, 1, .4f},
|
||||||
|
Vec3{-1, -1, .4f}, Vec3{1, 1, .4f}, Vec3{-1, 1, .4f}})
|
||||||
|
opaque->vertices.push_back({point, {0, 0, 0}, {1, 0, 0, 1}});
|
||||||
|
scene.draws.push_back({opaque, identity, {1, 1, 1, 1}, .5f, 0, false});
|
||||||
|
front.position[2] = .8f;
|
||||||
|
scene.sprites = {front};
|
||||||
|
color(255, 0, "Sprites behind opaque mesh depth must remain occluded");
|
||||||
|
front.position[2] = .2f;
|
||||||
|
scene.sprites = {front};
|
||||||
|
color(127, 128, "Sprites in front of opaque mesh depth must composite normally");
|
||||||
|
std::cout << "Sprite depth/layer ordering, stable ties, color/texture alpha and opaque "
|
||||||
|
"mesh occlusion passed\n";
|
||||||
|
return 0;
|
||||||
|
} catch (const std::exception& error) {
|
||||||
|
std::cerr << error.what() << '\n';
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,10 +1,13 @@
|
|||||||
#include <SDL3/SDL.h>
|
#include <SDL3/SDL.h>
|
||||||
|
#include <algorithm>
|
||||||
#include <chrono>
|
#include <chrono>
|
||||||
|
#include <cmath>
|
||||||
#include <cstdlib>
|
#include <cstdlib>
|
||||||
#include <faset/render/renderer.hpp>
|
#include <faset/render/renderer.hpp>
|
||||||
#include <filesystem>
|
#include <filesystem>
|
||||||
#include <iostream>
|
#include <iostream>
|
||||||
#include <stdexcept>
|
#include <stdexcept>
|
||||||
|
#include <string>
|
||||||
#include <thread>
|
#include <thread>
|
||||||
|
|
||||||
namespace {
|
namespace {
|
||||||
@@ -66,6 +69,12 @@ int main(int argc, char** argv) {
|
|||||||
unsigned frame{};
|
unsigned frame{};
|
||||||
auto draw = [&] {
|
auto draw = [&] {
|
||||||
renderer.poll_events();
|
renderer.poll_events();
|
||||||
|
const float native_scale = SDL_GetWindowDisplayScale(window);
|
||||||
|
require(std::isfinite(renderer.display_scale()) && renderer.display_scale() > 0.f,
|
||||||
|
"Renderer UI display scale must be finite and positive");
|
||||||
|
if (std::isfinite(native_scale) && native_scale > 0.f)
|
||||||
|
require(std::abs(renderer.display_scale() - native_scale) < 0.001f,
|
||||||
|
"Renderer UI display scale must follow SDL's live window scale");
|
||||||
const unsigned channel = frame++ % 2;
|
const unsigned channel = frame++ % 2;
|
||||||
scene.ui_quads[0].color = channel ? Color{0, 1, 0, 1} : Color{1, 0, 0, 1};
|
scene.ui_quads[0].color = channel ? Color{0, 1, 0, 1} : Color{1, 0, 0, 1};
|
||||||
renderer.render(scene);
|
renderer.render(scene);
|
||||||
@@ -108,6 +117,61 @@ int main(int argc, char** argv) {
|
|||||||
draw();
|
draw();
|
||||||
SDL_Delay(10);
|
SDL_Delay(10);
|
||||||
}
|
}
|
||||||
|
{
|
||||||
|
// Do not replace an image/rich clipboard with its text projection.
|
||||||
|
// Plain text is restored without ever printing the saved contents.
|
||||||
|
std::size_t mime_count{};
|
||||||
|
auto** mime_types = SDL_GetClipboardMimeTypes(&mime_count);
|
||||||
|
bool plain_text_only = true;
|
||||||
|
for (std::size_t i = 0; i < mime_count; ++i) {
|
||||||
|
const std::string type = mime_types[i];
|
||||||
|
if (!type.starts_with("text/plain") && type != "UTF8_STRING" && type != "TEXT" &&
|
||||||
|
type != "STRING" && type != "COMPOUND_TEXT" && type != "TARGETS" &&
|
||||||
|
type != "TIMESTAMP" && type != "MULTIPLE")
|
||||||
|
plain_text_only = false;
|
||||||
|
}
|
||||||
|
SDL_free(mime_types);
|
||||||
|
if (plain_text_only) {
|
||||||
|
char* saved = SDL_GetClipboardText();
|
||||||
|
require(saved != nullptr, "Read clipboard before reversible fixture");
|
||||||
|
struct ClipboardRestore {
|
||||||
|
std::string saved;
|
||||||
|
bool active{true};
|
||||||
|
~ClipboardRestore() {
|
||||||
|
if (active)
|
||||||
|
SDL_SetClipboardText(saved.c_str());
|
||||||
|
}
|
||||||
|
} restore{saved};
|
||||||
|
SDL_free(saved);
|
||||||
|
const std::string fixture = "Faset clipboard Café 世界";
|
||||||
|
renderer.set_clipboard(fixture);
|
||||||
|
require(renderer.clipboard() == fixture, "Native Unicode clipboard roundtrip");
|
||||||
|
require(SDL_SetClipboardText(restore.saved.c_str()), "Restore saved clipboard");
|
||||||
|
restore.active = false;
|
||||||
|
std::cout << "clipboard=unicode_roundtrip_passed\n";
|
||||||
|
} else {
|
||||||
|
std::cout << "clipboard=skipped_to_preserve_non_text_payload\n";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
renderer.set_text_input(true);
|
||||||
|
require(SDL_TextInputActive(window), "Native SDL text input is active");
|
||||||
|
renderer.set_text_input_area(31.5f, 22.5f, 160.5f, 24.5f);
|
||||||
|
int logical_width{}, logical_height{}, pixel_width{}, pixel_height{}, cursor{};
|
||||||
|
SDL_GetWindowSize(window, &logical_width, &logical_height);
|
||||||
|
SDL_GetWindowSizeInPixels(window, &pixel_width, &pixel_height);
|
||||||
|
require(logical_width > 0 && logical_height > 0 && pixel_width > 0 && pixel_height > 0,
|
||||||
|
"Text input area has usable native window dimensions");
|
||||||
|
SDL_Rect native_area{};
|
||||||
|
require(SDL_GetTextInputArea(window, &native_area, &cursor), "Read native text input area");
|
||||||
|
const float sx = float(logical_width) / pixel_width,
|
||||||
|
sy = float(logical_height) / pixel_height;
|
||||||
|
require(native_area.x == int(31.5f * sx) && native_area.y == int(22.5f * sy) &&
|
||||||
|
native_area.w == std::max(1, int(160.5f * sx)) &&
|
||||||
|
native_area.h == std::max(1, int(24.5f * sy)) && cursor == 0,
|
||||||
|
"Drawable-pixel input area must reach SDL in logical window units");
|
||||||
|
renderer.set_text_input(false);
|
||||||
|
require(!SDL_TextInputActive(window), "Native SDL text input stops");
|
||||||
|
std::cout << "text_input_area=coordinate_conversion_passed\n";
|
||||||
require(SDL_MinimizeWindow(window), "Request minimizing the owned fixture window");
|
require(SDL_MinimizeWindow(window), "Request minimizing the owned fixture window");
|
||||||
if (!await([&] { return SDL_GetWindowFlags(window) & SDL_WINDOW_MINIMIZED; }, 2000)) {
|
if (!await([&] { return SDL_GetWindowFlags(window) & SDL_WINDOW_MINIMIZED; }, 2000)) {
|
||||||
std::cout << "SKIP: window system declined the minimize request\n";
|
std::cout << "SKIP: window system declined the minimize request\n";
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
#include "Gameplay.hpp"
|
#include "Gameplay.hpp"
|
||||||
#include <cmath>
|
#include <cmath>
|
||||||
#include <faset/runtime/Runtime.hpp>
|
#include <faset/runtime/Runtime.hpp>
|
||||||
|
#include <faset/runtime/schema.hpp>
|
||||||
#include <iostream>
|
#include <iostream>
|
||||||
#include <limits>
|
#include <limits>
|
||||||
#include <stdexcept>
|
#include <stdexcept>
|
||||||
@@ -174,6 +175,107 @@ void clockAndValidation() {
|
|||||||
auto schema = faset::gameplay::schema();
|
auto schema = faset::gameplay::schema();
|
||||||
check(schema.size() == 2, "sample has explicit metadata without world");
|
check(schema.size() == 2, "sample has explicit metadata without world");
|
||||||
}
|
}
|
||||||
|
void componentSchemaVersions() {
|
||||||
|
auto doc = scene();
|
||||||
|
auto object = entity("versioned");
|
||||||
|
auto custom =
|
||||||
|
component("test.versioned", {{"value", 42}, {"future_payload", {"kept", "opaque"}}});
|
||||||
|
custom["version"] = 2;
|
||||||
|
object["components"].push_back(custom);
|
||||||
|
auto data = component("test.data", {{"description", "no behavior required"}});
|
||||||
|
data["version"] = 3;
|
||||||
|
object["components"].push_back(data);
|
||||||
|
doc["entities"].push_back(object);
|
||||||
|
const auto source_before = doc;
|
||||||
|
const auto metadata =
|
||||||
|
Json::array({{{"id", "test.versioned"}, {"version", 2}, {"fields", Json::object()}},
|
||||||
|
{{"id", "test.data"}, {"version", 3}, {"fields", Json::object()}}});
|
||||||
|
validate_scene_schemas(doc, metadata);
|
||||||
|
validate_scene_schemas(doc, Json{{"types", metadata}});
|
||||||
|
check(doc == source_before, "schema validation must not migrate or mutate source");
|
||||||
|
int starts = 0;
|
||||||
|
Runtime world;
|
||||||
|
Behavior versioned;
|
||||||
|
versioned.onStart = [&](Runtime& runtime, EntityHandle handle, double) {
|
||||||
|
++starts;
|
||||||
|
check(runtime.fields(handle, "test.versioned") == custom["fields"],
|
||||||
|
"versioned behavior receives exact opaque fields");
|
||||||
|
};
|
||||||
|
world.registerBehavior("test.versioned", versioned);
|
||||||
|
validate_scene_schemas(doc, metadata);
|
||||||
|
check(starts == 0, "schema validation does not invoke gameplay");
|
||||||
|
world.load(doc);
|
||||||
|
const auto handle = world.find("versioned");
|
||||||
|
check(starts == 1 && world.fields(handle, "test.data") == data["fields"],
|
||||||
|
"Runtime accepts positive custom versions including data-only components");
|
||||||
|
auto added = component("test.added", {{"value", "deferred"}});
|
||||||
|
added["version"] = 4;
|
||||||
|
world.addComponent(handle, added);
|
||||||
|
world.singleStep();
|
||||||
|
check(world.fields(handle, "test.added") == added["fields"],
|
||||||
|
"deferred custom component accepts its positive version");
|
||||||
|
for (const auto& invalid_version : Json::array({0, -1, 1.5, "2", true, nullptr})) {
|
||||||
|
auto invalid = doc;
|
||||||
|
invalid["entities"][0]["components"][1]["version"] = invalid_version;
|
||||||
|
rejects([&] { world.load(invalid); }, "custom version must be a positive integer");
|
||||||
|
rejects([&] { validate_scene_schemas(invalid, metadata); },
|
||||||
|
"schema gate rejects malformed component version");
|
||||||
|
check(world.valid(handle), "invalid version preserves running world");
|
||||||
|
}
|
||||||
|
for (const std::string builtin :
|
||||||
|
{"faset.transform", "faset.sprite", "faset.mesh", "faset.camera", "faset.light",
|
||||||
|
"faset.rigid_body_2d", "faset.rigid_body_3d"}) {
|
||||||
|
auto invalid = scene();
|
||||||
|
auto native = component(builtin);
|
||||||
|
native["version"] = 2;
|
||||||
|
invalid["entities"].push_back({{"id", "native"}, {"components", Json::array({native})}});
|
||||||
|
rejects([&] { world.load(invalid); },
|
||||||
|
"all builtin components retain strict native version 1");
|
||||||
|
rejects([&] { validate_scene_schemas(invalid, metadata); },
|
||||||
|
"schema gate rejects builtin v2");
|
||||||
|
check(world.valid(handle), "invalid builtin load preserves running world");
|
||||||
|
}
|
||||||
|
auto bad_add = component("faset.camera");
|
||||||
|
bad_add["version"] = 2;
|
||||||
|
world.addComponent(handle, bad_add);
|
||||||
|
world.singleStep();
|
||||||
|
rejects([&] { world.fields(handle, "faset.camera"); },
|
||||||
|
"invalid deferred builtin version cannot enter the world");
|
||||||
|
rejects([&] { validate_scene_schemas(doc, Json::array()); },
|
||||||
|
"Player schema gate rejects missing gameplay module metadata");
|
||||||
|
auto mismatched = metadata;
|
||||||
|
mismatched[0]["version"] = 1;
|
||||||
|
rejects([&] { validate_scene_schemas(doc, mismatched); },
|
||||||
|
"Player schema gate rejects gameplay version mismatch");
|
||||||
|
auto unknown = doc;
|
||||||
|
unknown["entities"][0]["components"][1]["type"] = "test.missing";
|
||||||
|
unknown["entities"][0]["components"][1]["version"] = 1;
|
||||||
|
rejects([&] { validate_scene_schemas(unknown, metadata); },
|
||||||
|
"Unknown custom v1 must not silently run in Player");
|
||||||
|
auto duplicate = metadata;
|
||||||
|
duplicate.push_back(metadata[0]);
|
||||||
|
rejects([&] { validate_scene_schemas(doc, duplicate); },
|
||||||
|
"Ambiguous duplicate metadata TypeIds rejected");
|
||||||
|
for (int version : {1, 2}) {
|
||||||
|
auto overridden_native = metadata;
|
||||||
|
overridden_native.push_back({{"id", "faset.transform"}, {"version", version}});
|
||||||
|
rejects([&] { validate_scene_schemas(doc, overridden_native); },
|
||||||
|
"Gameplay cannot redeclare native TypeIds, even at native version 1");
|
||||||
|
}
|
||||||
|
auto invalid_schema = metadata;
|
||||||
|
invalid_schema[0]["version"] = "2";
|
||||||
|
rejects([&] { validate_scene_schemas(doc, invalid_schema); },
|
||||||
|
"Schema versions also require positive integers");
|
||||||
|
rejects([&] { validate_scene_schemas(doc, Json::object()); },
|
||||||
|
"Malformed schema manifest rejected");
|
||||||
|
auto legacy = scene();
|
||||||
|
legacy["entities"].push_back(entity("legacy"));
|
||||||
|
legacy["entities"][0]["components"][0].erase("version");
|
||||||
|
validate_scene_schemas(legacy, Json::array());
|
||||||
|
world.load(legacy);
|
||||||
|
check(bool(world.find("legacy")),
|
||||||
|
"Omitted component version retains development scene v1 compatibility");
|
||||||
|
}
|
||||||
void structuralFailuresAndCallbacks() {
|
void structuralFailuresAndCallbacks() {
|
||||||
Runtime world;
|
Runtime world;
|
||||||
auto doc = scene();
|
auto doc = scene();
|
||||||
@@ -315,6 +417,7 @@ int main() {
|
|||||||
run("Box3D", [] { physics(3); });
|
run("Box3D", [] { physics(3); });
|
||||||
run("Lifecycle", lifecycle);
|
run("Lifecycle", lifecycle);
|
||||||
run("Clock/validation", clockAndValidation);
|
run("Clock/validation", clockAndValidation);
|
||||||
|
run("Component schema versions", componentSchemaVersions);
|
||||||
run("Structural failures", structuralFailuresAndCallbacks);
|
run("Structural failures", structuralFailuresAndCallbacks);
|
||||||
run("Sample gameplay", sampleGameplay);
|
run("Sample gameplay", sampleGameplay);
|
||||||
run("Grounded jump", groundedJump);
|
run("Grounded jump", groundedJump);
|
||||||
|
|||||||
@@ -2,13 +2,13 @@
|
|||||||
#include <faset/ui/ui.hpp>
|
#include <faset/ui/ui.hpp>
|
||||||
#include <iostream>
|
#include <iostream>
|
||||||
using namespace faset;
|
using namespace faset;
|
||||||
int main(int argc, char** argv) {
|
int ui_render_main(int argc, char** argv) {
|
||||||
try {
|
try {
|
||||||
render::Renderer renderer({1280, 800, "Faset UI reference implementation", true, true});
|
render::Renderer renderer({1280, 800, "Faset UI reference implementation", true, true});
|
||||||
ui::Context ui(FASET_TEST_FONT);
|
ui::Context ui(path_from_utf8(FASET_TEST_FONT));
|
||||||
ui.set_theme(ui::Theme::load(FASET_UI_THEME));
|
ui.set_theme(ui::Theme::load(path_from_utf8(FASET_UI_THEME)));
|
||||||
ui.apply_layout(
|
ui.apply_layout(
|
||||||
read_json(std::filesystem::path(FASET_UI_THEME).parent_path() / "editor-layout.json"));
|
read_json(path_from_utf8(FASET_UI_THEME).parent_path() / "editor-layout.json"));
|
||||||
auto& menu = ui.find("menubar")->add(ui::Kind::Row, "menuitems");
|
auto& menu = ui.find("menubar")->add(ui::Kind::Row, "menuitems");
|
||||||
for (const auto& name : {"Faset", "File", "Edit", "Scene", "View", "Help"})
|
for (const auto& name : {"Faset", "File", "Edit", "Scene", "View", "Help"})
|
||||||
menu.add(ui::Kind::Button, "menu-" + std::string(name), name).layout.width = 65;
|
menu.add(ui::Kind::Button, "menu-" + std::string(name), name).layout.width = 65;
|
||||||
@@ -102,7 +102,7 @@ int main(int argc, char** argv) {
|
|||||||
snapshot.draws.push_back(crate);
|
snapshot.draws.push_back(crate);
|
||||||
ui.draw(snapshot);
|
ui.draw(snapshot);
|
||||||
renderer.render(snapshot);
|
renderer.render(snapshot);
|
||||||
renderer.capture(argc > 1 ? argv[1] : "ui-test.ppm");
|
renderer.capture(path_from_utf8(argc > 1 ? argv[1] : "ui-test.ppm"));
|
||||||
if (renderer.stats().validation_errors)
|
if (renderer.stats().validation_errors)
|
||||||
throw std::runtime_error("Vulkan validation reported UI rendering errors");
|
throw std::runtime_error("Vulkan validation reported UI rendering errors");
|
||||||
std::cout << "UI glyph atlas and retained panels rendered on " << renderer.stats().device
|
std::cout << "UI glyph atlas and retained panels rendered on " << renderer.stats().device
|
||||||
@@ -113,3 +113,13 @@ int main(int argc, char** argv) {
|
|||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#ifdef _WIN32
|
||||||
|
int wmain(int argc, wchar_t** argv) {
|
||||||
|
return run_utf8_main(argc, argv, ui_render_main);
|
||||||
|
}
|
||||||
|
#else
|
||||||
|
int main(int argc, char** argv) {
|
||||||
|
return ui_render_main(argc, argv);
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|||||||
+113
-4
@@ -1,4 +1,7 @@
|
|||||||
|
#include <algorithm>
|
||||||
#include <chrono>
|
#include <chrono>
|
||||||
|
#include <cmath>
|
||||||
|
#include <faset/core/io.hpp>
|
||||||
#include <faset/ui/ui.hpp>
|
#include <faset/ui/ui.hpp>
|
||||||
#include <iostream>
|
#include <iostream>
|
||||||
#include <stdexcept>
|
#include <stdexcept>
|
||||||
@@ -35,9 +38,85 @@ void click(ui::Context& context, const ui::Widget& widget) {
|
|||||||
context.handle(mouse(render::Event::Type::MouseDown, r.x + 5, r.y + 5));
|
context.handle(mouse(render::Event::Type::MouseDown, r.x + 5, r.y + 5));
|
||||||
context.handle(mouse(render::Event::Type::MouseUp, r.x + 5, r.y + 5));
|
context.handle(mouse(render::Event::Type::MouseUp, r.x + 5, r.y + 5));
|
||||||
}
|
}
|
||||||
|
void display_scale_contract() {
|
||||||
|
ui::Context context(path_from_utf8(FASET_TEST_FONT));
|
||||||
|
auto& field = context.root().add(ui::Kind::TextField, "dpi-field", "Ж");
|
||||||
|
field.layout.width = 180;
|
||||||
|
field.layout.height = 32;
|
||||||
|
auto& number = context.root().add(ui::Kind::NumberField, "dpi-number");
|
||||||
|
number.layout.height = 32;
|
||||||
|
number.value = 4;
|
||||||
|
number.step = 1;
|
||||||
|
auto& button = context.root().add(ui::Kind::Button, "dpi-action", "Open");
|
||||||
|
button.layout.width = 180;
|
||||||
|
button.layout.height = 32;
|
||||||
|
int clicks = 0, commits = 0, number_commits = 0;
|
||||||
|
button.on_click = [&](ui::Widget&) { ++clicks; };
|
||||||
|
field.on_commit = [&](ui::Widget&) { ++commits; };
|
||||||
|
number.on_commit = [&](ui::Widget&) { ++number_commits; };
|
||||||
|
ui::Rect ime_area;
|
||||||
|
context.set_ime([](bool) {}, [&](ui::Rect rect) { ime_area = rect; });
|
||||||
|
context.layout(320, 240, 1);
|
||||||
|
const auto initial = field.rect;
|
||||||
|
render::Snapshot one;
|
||||||
|
context.draw(one);
|
||||||
|
const auto first_glyph = std::find_if(one.ui_quads.begin(), one.ui_quads.end(),
|
||||||
|
[](const auto& q) { return bool(q.texture); });
|
||||||
|
check(first_glyph != one.ui_quads.end(), "1x rasterized text missing");
|
||||||
|
const auto glyph_width = first_glyph->width, glyph_height = first_glyph->height;
|
||||||
|
const auto revision = context.font().texture()->revision;
|
||||||
|
check(context.focus("dpi-field"), "DPI field focus");
|
||||||
|
context.handle(key("A", true));
|
||||||
|
context.handle(text("Живая сцена"));
|
||||||
|
context.layout(640, 480, 2);
|
||||||
|
check(field.rect.width == initial.width * 2 && field.rect.height == initial.height * 2 &&
|
||||||
|
field.clip.width == initial.width * 2 && ime_area.width == field.rect.width,
|
||||||
|
"2x layout, clipping and IME rectangle use drawable pixels");
|
||||||
|
check(context.focused_id() == "dpi-field" && field.text == "Живая сцена" && commits == 0,
|
||||||
|
"Live display scale preserves focused edit without committing");
|
||||||
|
context.handle(key("Escape"));
|
||||||
|
render::Snapshot two;
|
||||||
|
context.draw(two);
|
||||||
|
const auto second_glyph = std::find_if(two.ui_quads.begin(), two.ui_quads.end(),
|
||||||
|
[](const auto& q) { return bool(q.texture); });
|
||||||
|
check(second_glyph != two.ui_quads.end() && second_glyph->width >= glyph_width * 1.8f &&
|
||||||
|
second_glyph->height >= glyph_height * 1.8f &&
|
||||||
|
context.font().texture()->revision > revision,
|
||||||
|
"2x display requests larger glyph rasterization");
|
||||||
|
check(std::abs(second_glyph->width - (second_glyph->uv_rect[2] - second_glyph->uv_rect[0]) *
|
||||||
|
second_glyph->texture->width) < .1f,
|
||||||
|
"High-DPI glyphs use one atlas texel per pixel, not stretched 1x bitmaps");
|
||||||
|
context.handle(mouse(render::Event::Type::MouseDown, button.rect.x + 300, button.rect.y + 40));
|
||||||
|
context.handle(mouse(render::Event::Type::MouseUp, button.rect.x + 300, button.rect.y + 40));
|
||||||
|
check(clicks == 1, "Hit testing reaches the second half of a scaled control");
|
||||||
|
const auto r = number.rect;
|
||||||
|
context.handle(mouse(render::Event::Type::MouseDown, r.x + 20, r.y + 20));
|
||||||
|
context.handle(mouse(render::Event::Type::MouseMove, r.x + 40, r.y + 20));
|
||||||
|
check(number.value == 14, "Numeric drag uses logical distance at 2x");
|
||||||
|
context.layout(320, 240, 1);
|
||||||
|
context.handle(mouse(render::Event::Type::MouseUp, 40, 52));
|
||||||
|
check(number.value == 4 && number_commits == 0,
|
||||||
|
"Monitor scale change cancels pointer drag without an accidental commit");
|
||||||
|
check(field.rect.width == initial.width && button.layout.width == 180,
|
||||||
|
"Returning to 1x preserves logical layout metrics");
|
||||||
|
ui::Context scroll(path_from_utf8(FASET_TEST_FONT));
|
||||||
|
auto& panel = scroll.root().add(ui::Kind::Column, "scroll");
|
||||||
|
panel.layout.height = 80;
|
||||||
|
panel.layout.scroll = true;
|
||||||
|
panel.layout.gap = 0;
|
||||||
|
for (int i = 0; i < 10; ++i)
|
||||||
|
panel.add(ui::Kind::Button, "row-" + std::to_string(i), "Row").layout.height = 30;
|
||||||
|
scroll.layout(200, 100, 1);
|
||||||
|
panel.scroll_y = 30;
|
||||||
|
scroll.layout(200, 100, 1);
|
||||||
|
scroll.layout(400, 200, 2);
|
||||||
|
check(panel.scroll_y == 60 && panel.children[1]->rect.y == panel.rect.y,
|
||||||
|
"DPI changes retain the logical scroll position");
|
||||||
|
}
|
||||||
} // namespace
|
} // namespace
|
||||||
int main() {
|
int main() {
|
||||||
try {
|
try {
|
||||||
|
display_scale_contract();
|
||||||
ui::TextBuffer buffer("Привет");
|
ui::TextBuffer buffer("Привет");
|
||||||
check(buffer.backspace() && buffer.text() == "Приве", "UTF-8 backspace split codepoint");
|
check(buffer.backspace() && buffer.text() == "Приве", "UTF-8 backspace split codepoint");
|
||||||
check(buffer.undo() && buffer.text() == "Привет", "text undo");
|
check(buffer.undo() && buffer.text() == "Привет", "text undo");
|
||||||
@@ -53,7 +132,37 @@ int main() {
|
|||||||
check(buffer.text() == "верца", "UTF-8 delete");
|
check(buffer.text() == "верца", "UTF-8 delete");
|
||||||
buffer.undo();
|
buffer.undo();
|
||||||
check(buffer.text() == "Дверца", "undo delete");
|
check(buffer.text() == "Дверца", "undo delete");
|
||||||
ui::Context context(FASET_TEST_FONT);
|
const auto missing_font = std::filesystem::temp_directory_path() /
|
||||||
|
path_from_utf8("Недоступный шрифт-" + new_id() + ".ttf");
|
||||||
|
bool unicode_font_diagnostic = false;
|
||||||
|
try {
|
||||||
|
ui::FontAtlas missing(missing_font);
|
||||||
|
} catch (const std::exception& error) {
|
||||||
|
unicode_font_diagnostic =
|
||||||
|
std::string(error.what()).find(path_to_utf8(missing_font)) != std::string::npos;
|
||||||
|
}
|
||||||
|
check(unicode_font_diagnostic, "Font errors preserve the UTF-8 path");
|
||||||
|
const auto unicode_font_directory =
|
||||||
|
std::filesystem::temp_directory_path() / path_from_utf8("Faset шрифты-" + new_id()) /
|
||||||
|
std::string(80, 'a') / std::string(80, 'b') / std::string(80, 'c');
|
||||||
|
std::filesystem::create_directories(unicode_font_directory);
|
||||||
|
const auto unicode_font_path =
|
||||||
|
unicode_font_directory / path_from_utf8("Основной шрифт.ttf");
|
||||||
|
std::filesystem::copy_file(path_from_utf8(FASET_TEST_FONT), unicode_font_path);
|
||||||
|
{
|
||||||
|
ui::FontAtlas unicode_font(unicode_font_path);
|
||||||
|
check(std::filesystem::remove(unicode_font_path),
|
||||||
|
"Unicode font file must be closed after loading");
|
||||||
|
check(unicode_font.measure("Живая сцена", 18) > 0,
|
||||||
|
"Shaping uses retained font bytes after Unicode source file is removed");
|
||||||
|
render::Snapshot glyphs;
|
||||||
|
unicode_font.draw(glyphs, "Живая сцена", 0, 0, 18, {1, 1, 1, 1}, {0, 0, 400, 40});
|
||||||
|
check(!glyphs.ui_quads.empty() && unicode_font.texture()->revision > 1,
|
||||||
|
"Rasterization remains valid after Unicode font source disappears");
|
||||||
|
}
|
||||||
|
std::filesystem::remove_all(
|
||||||
|
unicode_font_directory.parent_path().parent_path().parent_path());
|
||||||
|
ui::Context context(path_from_utf8(FASET_TEST_FONT));
|
||||||
auto& root = context.root();
|
auto& root = context.root();
|
||||||
root.layout.gap = 4;
|
root.layout.gap = 4;
|
||||||
auto& name = root.add(ui::Kind::TextField, "name", "Door");
|
auto& name = root.add(ui::Kind::TextField, "name", "Door");
|
||||||
@@ -137,7 +246,7 @@ int main() {
|
|||||||
context.handle(key("Tab"));
|
context.handle(key("Tab"));
|
||||||
check(context.focused_id() == "name", "focus wraps predictably");
|
check(context.focused_id() == "name", "focus wraps predictably");
|
||||||
check(ime_rectangles > 0, "IME rectangle never sent");
|
check(ime_rectangles > 0, "IME rectangle never sent");
|
||||||
ui::Context split(FASET_TEST_FONT);
|
ui::Context split(path_from_utf8(FASET_TEST_FONT));
|
||||||
auto& row = split.root().add(ui::Kind::Row, "row");
|
auto& row = split.root().add(ui::Kind::Row, "row");
|
||||||
row.layout.flex = 1;
|
row.layout.flex = 1;
|
||||||
row.layout.gap = 0;
|
row.layout.gap = 0;
|
||||||
@@ -171,7 +280,7 @@ int main() {
|
|||||||
context.handle(key("Escape"));
|
context.handle(key("Escape"));
|
||||||
check(number.value == 19 && number_commits == 1 && cancellations == 1,
|
check(number.value == 19 && number_commits == 1 && cancellations == 1,
|
||||||
"Escape must cancel numeric drag");
|
"Escape must cancel numeric drag");
|
||||||
ui::Context scrolling(FASET_TEST_FONT);
|
ui::Context scrolling(path_from_utf8(FASET_TEST_FONT));
|
||||||
auto& list = scrolling.root().add(ui::Kind::Panel, "list");
|
auto& list = scrolling.root().add(ui::Kind::Panel, "list");
|
||||||
list.layout.height = 80;
|
list.layout.height = 80;
|
||||||
list.layout.scroll = true;
|
list.layout.scroll = true;
|
||||||
@@ -211,7 +320,7 @@ int main() {
|
|||||||
rejected = true;
|
rejected = true;
|
||||||
}
|
}
|
||||||
check(rejected && restored.to_json() == state, "invalid dock load mutated existing layout");
|
check(rejected && restored.to_json() == state, "invalid dock load mutated existing layout");
|
||||||
ui::Context declarative(FASET_TEST_FONT);
|
ui::Context declarative(path_from_utf8(FASET_TEST_FONT));
|
||||||
declarative.apply_layout({{"id", "root"},
|
declarative.apply_layout({{"id", "root"},
|
||||||
{"kind", "column"},
|
{"kind", "column"},
|
||||||
{"children", ui::Json::array({{{"id", "run"},
|
{"children", ui::Json::array({{{"id", "run"},
|
||||||
|
|||||||
@@ -89,12 +89,17 @@ def main() -> int:
|
|||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
if args.cache_key:
|
if args.cache_key:
|
||||||
identity = cache_identity()
|
identity = cache_identity()
|
||||||
key = "windows-2025-vulkan-v2-" + identity
|
# Cache restoration can prefix-match the primary key. Keep SDK-only and
|
||||||
|
# full-driver keys in disjoint namespaces, never suffix variants.
|
||||||
|
key = "windows-2025-vulkan-full-v3-" + identity
|
||||||
|
sdk_key = "windows-2025-vulkan-sdk-v3-" + identity
|
||||||
legacy = LEGACY_CACHE_KEY if identity == LEGACY_SOURCE_IDENTITY else ""
|
legacy = LEGACY_CACHE_KEY if identity == LEGACY_SOURCE_IDENTITY else ""
|
||||||
|
sdk_legacy = ("windows-2025-vulkan-v2-" + identity + "-loader-only"
|
||||||
|
if identity == LEGACY_SOURCE_IDENTITY else "")
|
||||||
print(key)
|
print(key)
|
||||||
if "GITHUB_OUTPUT" in os.environ:
|
if "GITHUB_OUTPUT" in os.environ:
|
||||||
with open(os.environ["GITHUB_OUTPUT"], "a", encoding="utf-8") as output:
|
with open(os.environ["GITHUB_OUTPUT"], "a", encoding="utf-8") as output:
|
||||||
output.write(f"key={key}\nlegacy={legacy}\n")
|
output.write(f"key={key}\nsdk_key={sdk_key}\nlegacy={legacy}\nsdk_legacy={sdk_legacy}\n")
|
||||||
return 0
|
return 0
|
||||||
if sys.platform != "win32":
|
if sys.platform != "win32":
|
||||||
parser.error("Run this helper on Windows in a Visual Studio x64 developer environment")
|
parser.error("Run this helper on Windows in a Visual Studio x64 developer environment")
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import tempfile
|
|||||||
ROOT = pathlib.Path(__file__).resolve().parents[1]
|
ROOT = pathlib.Path(__file__).resolve().parents[1]
|
||||||
|
|
||||||
|
|
||||||
def verify(blender, editor, output):
|
def verify(blender, editor, output, ui_probe=None):
|
||||||
fixture = output / "fixture"
|
fixture = output / "fixture"
|
||||||
result = subprocess.run([str(blender), "--background", "--factory-startup", "--python-exit-code", "1",
|
result = subprocess.run([str(blender), "--background", "--factory-startup", "--python-exit-code", "1",
|
||||||
"--python", str(ROOT / "tests/blender/generate_fixture.py"), "--", str(ROOT), str(fixture)],
|
"--python", str(ROOT / "tests/blender/generate_fixture.py"), "--", str(ROOT), str(fixture)],
|
||||||
@@ -64,6 +64,12 @@ def verify(blender, editor, output):
|
|||||||
"renamed_generation": renamed["result"]["generation"],
|
"renamed_generation": renamed["result"]["generation"],
|
||||||
"stable_output_ids": True, "removed_output_conflict": True,
|
"stable_output_ids": True, "removed_output_conflict": True,
|
||||||
"failed_import_keeps_generation": True, "authoring_preserved": True}
|
"failed_import_keeps_generation": True, "authoring_preserved": True}
|
||||||
|
if ui_probe:
|
||||||
|
probe = subprocess.run([str(ui_probe), str(fixture), str(output / "live-editor")],
|
||||||
|
capture_output=True, text=True, encoding="utf-8")
|
||||||
|
(output / "live-editor.log").write_text(probe.stdout + probe.stderr, encoding="utf-8")
|
||||||
|
probe.check_returncode()
|
||||||
|
report["live_editor"] = json.loads((output / "live-editor/probe.json").read_text())
|
||||||
(output / "report.json").write_text(json.dumps(report, indent=2) + "\n")
|
(output / "report.json").write_text(json.dumps(report, indent=2) + "\n")
|
||||||
print(json.dumps(report, indent=2))
|
print(json.dumps(report, indent=2))
|
||||||
|
|
||||||
@@ -73,10 +79,14 @@ if __name__ == "__main__":
|
|||||||
parser.add_argument("--blender", type=pathlib.Path, required=True)
|
parser.add_argument("--blender", type=pathlib.Path, required=True)
|
||||||
parser.add_argument("--editor", type=pathlib.Path, required=True)
|
parser.add_argument("--editor", type=pathlib.Path, required=True)
|
||||||
parser.add_argument("--output", type=pathlib.Path)
|
parser.add_argument("--output", type=pathlib.Path)
|
||||||
|
parser.add_argument("--ui-probe", type=pathlib.Path,
|
||||||
|
help="faset_blender_editor_probe: verify two live viewport instances (Vulkan required)")
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
if args.output:
|
if args.output:
|
||||||
args.output.resolve().mkdir(parents=True, exist_ok=True)
|
args.output.resolve().mkdir(parents=True, exist_ok=True)
|
||||||
verify(args.blender.resolve(), args.editor.resolve(), args.output.resolve())
|
verify(args.blender.resolve(), args.editor.resolve(), args.output.resolve(),
|
||||||
|
args.ui_probe.resolve() if args.ui_probe else None)
|
||||||
else:
|
else:
|
||||||
with tempfile.TemporaryDirectory(prefix="faset-blender-") as temporary:
|
with tempfile.TemporaryDirectory(prefix="faset-blender-") as temporary:
|
||||||
verify(args.blender.resolve(), args.editor.resolve(), pathlib.Path(temporary))
|
verify(args.blender.resolve(), args.editor.resolve(), pathlib.Path(temporary),
|
||||||
|
args.ui_probe.resolve() if args.ui_probe else None)
|
||||||
|
|||||||
@@ -0,0 +1,181 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Exercise crash recovery, failed Player startup and failed C++ build over real MCP.
|
||||||
|
|
||||||
|
Uses a disposable copy of the 2D sample. Requires native build tools and a Vulkan
|
||||||
|
desktop for the successful Play/Stop check. No runtime entity API is used.
|
||||||
|
"""
|
||||||
|
import argparse
|
||||||
|
import datetime
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
import platform
|
||||||
|
import queue
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
|
||||||
|
|
||||||
|
class Client:
|
||||||
|
def __init__(self, editor, engine, project):
|
||||||
|
self.process = subprocess.Popen([str(editor), "--engine", str(engine), "--project",
|
||||||
|
str(project), "--mcp"], stdin=subprocess.PIPE, stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.PIPE, text=True, encoding="utf-8")
|
||||||
|
self.output, self.errors, self.sequence = queue.Queue(), [], 0
|
||||||
|
def output():
|
||||||
|
try:
|
||||||
|
for line in self.process.stdout:
|
||||||
|
self.output.put(json.loads(line))
|
||||||
|
except Exception as error:
|
||||||
|
self.output.put(error)
|
||||||
|
finally:
|
||||||
|
self.output.put(None)
|
||||||
|
def errors():
|
||||||
|
for line in self.process.stderr:
|
||||||
|
self.errors.append(line)
|
||||||
|
self.readers = [threading.Thread(target=output, daemon=True), threading.Thread(target=errors, daemon=True)]
|
||||||
|
for reader in self.readers:
|
||||||
|
reader.start()
|
||||||
|
self.request("initialize", {"protocolVersion": "2025-06-18", "capabilities": {},
|
||||||
|
"clientInfo": {"name": "recovery-acceptance", "version": "1"}})
|
||||||
|
self.process.stdin.write('{"jsonrpc":"2.0","method":"notifications/initialized"}\n')
|
||||||
|
self.process.stdin.flush()
|
||||||
|
|
||||||
|
def request(self, method, params):
|
||||||
|
self.sequence += 1
|
||||||
|
self.process.stdin.write(json.dumps({"jsonrpc": "2.0", "id": self.sequence,
|
||||||
|
"method": method, "params": params}) + "\n")
|
||||||
|
self.process.stdin.flush()
|
||||||
|
response = self.output.get(timeout=30)
|
||||||
|
assert isinstance(response, dict) and response.get("id") == self.sequence, response
|
||||||
|
assert "error" not in response, response
|
||||||
|
return response["result"]
|
||||||
|
|
||||||
|
def call(self, name, arguments=None, error=False):
|
||||||
|
result = self.request("tools/call", {"name": name, "arguments": arguments or {}})
|
||||||
|
assert result.get("isError", False) == error, result
|
||||||
|
return result["structuredContent"]
|
||||||
|
|
||||||
|
def job(self, identity, expected="succeeded"):
|
||||||
|
deadline = time.monotonic() + 1800
|
||||||
|
while time.monotonic() < deadline:
|
||||||
|
value = self.call("faset_job", {"id": identity})
|
||||||
|
if value["state"] in ("succeeded", "failed", "cancelled", "conflict"):
|
||||||
|
assert value["state"] == expected, value
|
||||||
|
return value
|
||||||
|
time.sleep(.1)
|
||||||
|
raise RuntimeError("Editor job timed out")
|
||||||
|
|
||||||
|
def close(self, crash=False):
|
||||||
|
if self.process.poll() is None:
|
||||||
|
if crash:
|
||||||
|
self.process.kill()
|
||||||
|
else:
|
||||||
|
self.process.stdin.close()
|
||||||
|
try:
|
||||||
|
self.process.wait(timeout=30)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
self.process.kill()
|
||||||
|
self.process.wait()
|
||||||
|
raise
|
||||||
|
for reader in self.readers:
|
||||||
|
reader.join(timeout=5)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument("--editor", type=Path, required=True)
|
||||||
|
parser.add_argument("--engine", type=Path, default=Path(__file__).resolve().parents[1])
|
||||||
|
parser.add_argument("--output", type=Path, required=True)
|
||||||
|
args = parser.parse_args()
|
||||||
|
engine, editor, output = args.engine.resolve(), args.editor.resolve(), args.output.resolve()
|
||||||
|
if output.exists():
|
||||||
|
parser.error("Use a new output directory")
|
||||||
|
output.mkdir(parents=True)
|
||||||
|
project = output / "project"
|
||||||
|
shutil.copytree(engine / "examples/projects/collect-2d", project,
|
||||||
|
ignore=shutil.ignore_patterns(".faset", "Exports"))
|
||||||
|
checks = []
|
||||||
|
def record(name):
|
||||||
|
checks.append(name)
|
||||||
|
(output / "progress.json").write_text(json.dumps(checks, indent=2) + "\n")
|
||||||
|
print(name, flush=True)
|
||||||
|
client = Client(editor, engine, project)
|
||||||
|
try:
|
||||||
|
doc = client.call("faset_documents")["documents"][0]
|
||||||
|
identity = doc["id"]
|
||||||
|
client.call("faset_document_save", {"document": identity})
|
||||||
|
before = client.call("faset_document_query", {"document": identity})
|
||||||
|
changed = client.call("faset_scene_edit", {"document": identity, "revision": before["revision"],
|
||||||
|
"operations": [{"op": "scene.rename", "name": "Unsaved recovery acceptance"}]})
|
||||||
|
client.close(crash=True) # Kill only our idle owned Editor, after transaction acknowledgement.
|
||||||
|
client = Client(editor, engine, project)
|
||||||
|
reopened = client.call("faset_document_query", {"document": identity})
|
||||||
|
assert reopened["scene"]["name"] == before["scene"]["name"]
|
||||||
|
restored = client.call("faset_recovery_restore", {"document": identity, "revision": reopened["revision"]})
|
||||||
|
assert restored["scene"] == changed["scene"]
|
||||||
|
record("acknowledged unsaved edit survives killed Editor and explicit recovery")
|
||||||
|
(project / "Scenes/blocked.scene.json").mkdir()
|
||||||
|
client.call("faset_document_save", {"document": identity, "path": "Scenes/blocked.scene.json"}, error=True)
|
||||||
|
assert client.call("faset_document_query", {"document": identity})["scene"] == restored["scene"]
|
||||||
|
assert json.loads((project / "Scenes/main.scene.json").read_text())["name"] == before["scene"]["name"]
|
||||||
|
record("failed save preserves live authoring and last saved file")
|
||||||
|
bad = client.call("faset_scene_edit", {"document": identity, "revision": restored["revision"],
|
||||||
|
"operations": [{"op": "entity.create", "entity": {
|
||||||
|
"id": "future-version-object", "name": "Future component recovery fixture", "parent": None,
|
||||||
|
"components": [{"id": "future-transform", "type": "faset.transform", "version": 999,
|
||||||
|
"fields": {"kept": True}}]}}]})
|
||||||
|
play = client.call("faset_play", {"document": identity})
|
||||||
|
client.job(play["job"])
|
||||||
|
deadline = time.monotonic() + 30
|
||||||
|
while time.monotonic() < deadline:
|
||||||
|
logs = client.call("faset_editor_logs")["logs"]
|
||||||
|
if any("Player exited with code 1" in line for line in logs):
|
||||||
|
break
|
||||||
|
time.sleep(.05)
|
||||||
|
else:
|
||||||
|
raise RuntimeError("Expected failed Player startup was not observed: " + str(logs))
|
||||||
|
assert client.call("faset_document_query", {"document": identity})["scene"] == bad["scene"]
|
||||||
|
record("failed Player process is reported without changing authoring data")
|
||||||
|
restored = client.call("faset_undo", {"document": identity, "revision": bad["revision"]})
|
||||||
|
assert restored["scene"] == changed["scene"]
|
||||||
|
old_logs = len(client.call("faset_editor_logs")["logs"])
|
||||||
|
client.job(client.call("faset_play", {"document": identity})["job"])
|
||||||
|
time.sleep(2)
|
||||||
|
logs = client.call("faset_editor_logs")["logs"][old_logs:]
|
||||||
|
assert any("Play started" in line for line in logs), logs
|
||||||
|
assert not any("Player exited with code" in line for line in logs), logs
|
||||||
|
client.call("faset_stop")
|
||||||
|
assert client.call("faset_document_query", {"document": identity})["scene"] == restored["scene"]
|
||||||
|
record("Undo repairs the scene, then Play and Stop leave authoring unchanged")
|
||||||
|
metadata = client.call("faset_schema")
|
||||||
|
gameplay = project / "Scripts/Gameplay.cpp"
|
||||||
|
original = gameplay.read_bytes()
|
||||||
|
gameplay.write_bytes(original + b"\n#error intentional_recovery_acceptance_compile_failure\n")
|
||||||
|
try:
|
||||||
|
client.job(client.call("faset_build")["job"], "failed")
|
||||||
|
assert client.call("faset_schema") == metadata
|
||||||
|
assert client.call("faset_schema_status")["stale"]
|
||||||
|
record("failed C++ build keeps prior schema and reports stale status")
|
||||||
|
finally:
|
||||||
|
gameplay.write_bytes(original)
|
||||||
|
client.job(client.call("faset_build")["job"])
|
||||||
|
assert not client.call("faset_schema_status")["stale"]
|
||||||
|
record("fixed C++ rebuild restores current schema status")
|
||||||
|
result = client.call("faset_import", {"path": "Assets/missing.glb"})
|
||||||
|
client.job(result["job"], "failed")
|
||||||
|
assert client.call("faset_document_query", {"document": identity})["scene"] == restored["scene"]
|
||||||
|
record("failed import preserves authoring")
|
||||||
|
(output / "report.json").write_text(json.dumps({"format": "faset.editor-recovery-verification",
|
||||||
|
"version": 1, "status": "passed", "checks": checks,
|
||||||
|
"recorded_at_utc": datetime.datetime.now(datetime.timezone.utc).isoformat(),
|
||||||
|
"platform": platform.platform(),
|
||||||
|
"editor_sha256": hashlib.sha256(editor.read_bytes()).hexdigest(),
|
||||||
|
"logs": client.call("faset_editor_logs")["logs"]}, indent=2) + "\n")
|
||||||
|
finally:
|
||||||
|
client.close()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -109,6 +109,8 @@ def verify_capture(path: Path) -> dict:
|
|||||||
|
|
||||||
|
|
||||||
def main() -> int:
|
def main() -> int:
|
||||||
|
# Keep captured CI logs portable even when the Windows console uses a legacy code page.
|
||||||
|
sys.stdout.reconfigure(encoding="utf-8")
|
||||||
parser = argparse.ArgumentParser(description=__doc__)
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
parser.add_argument("--engine", type=Path, default=Path(__file__).resolve().parents[1])
|
parser.add_argument("--engine", type=Path, default=Path(__file__).resolve().parents[1])
|
||||||
parser.add_argument("--editor", type=Path, required=True)
|
parser.add_argument("--editor", type=Path, required=True)
|
||||||
@@ -127,9 +129,11 @@ def main() -> int:
|
|||||||
require(not standalone.exists() or not any(standalone.iterdir()),
|
require(not standalone.exists() or not any(standalone.iterdir()),
|
||||||
"Standalone root must be new/empty")
|
"Standalone root must be new/empty")
|
||||||
standalone.mkdir(parents=True, exist_ok=True)
|
standalone.mkdir(parents=True, exist_ok=True)
|
||||||
evidence, projects = output / "evidence", output / "projects"
|
evidence, projects = output / "evidence", output / "Faset Café 世界"
|
||||||
evidence.mkdir()
|
evidence.mkdir()
|
||||||
projects.mkdir()
|
projects.mkdir()
|
||||||
|
standalone_projects = standalone / "Faset Café 世界"
|
||||||
|
standalone_projects.mkdir()
|
||||||
report = {"format": "faset.playable-export-verification", "version": 1,
|
report = {"format": "faset.playable-export-verification", "version": 1,
|
||||||
"started_utc": datetime.now(timezone.utc).isoformat(), "platform": sys.platform,
|
"started_utc": datetime.now(timezone.utc).isoformat(), "platform": sys.platform,
|
||||||
"engine": str(engine), "editor": str(editor), "standalone_root": str(standalone),
|
"engine": str(engine), "editor": str(editor), "standalone_root": str(standalone),
|
||||||
@@ -162,7 +166,7 @@ def main() -> int:
|
|||||||
manifest = verify_package(generation)
|
manifest = verify_package(generation)
|
||||||
require(dimension != 3 or bool(manifest["asset_generations"]),
|
require(dimension != 3 or bool(manifest["asset_generations"]),
|
||||||
"3D game did not package its imported Blender asset")
|
"3D game did not package its imported Blender asset")
|
||||||
relocated = standalone / name
|
relocated = standalone_projects / name
|
||||||
shutil.copytree(generation, relocated)
|
shutil.copytree(generation, relocated)
|
||||||
verify_package(relocated)
|
verify_package(relocated)
|
||||||
shutil.copy2(relocated / "manifest.json", evidence / f"{name}-manifest.json")
|
shutil.copy2(relocated / "manifest.json", evidence / f"{name}-manifest.json")
|
||||||
|
|||||||
Reference in New Issue
Block a user