Checkpoint 4: harden native paths, authoring workflows and Player lifecycle
This commit is contained in:
+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);
|
||||
const auto relative =
|
||||
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");
|
||||
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()));
|
||||
return Json{{"path", relative},
|
||||
{"mimeType", "image/png"},
|
||||
|
||||
+17
-7
@@ -47,11 +47,11 @@ void help() {
|
||||
"MCP uses JSON-RPC over stdio and only exposes authoring/editor services.\n";
|
||||
}
|
||||
} // namespace
|
||||
int main(int argc, char** argv) {
|
||||
int editor_main(int argc, char** argv) {
|
||||
using namespace faset;
|
||||
using namespace faset::editor;
|
||||
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;
|
||||
int dimension = 3;
|
||||
bool mcp = false, gui = true, explicit_gui = false, wait = false;
|
||||
@@ -67,15 +67,15 @@ int main(int argc, char** argv) {
|
||||
return 0;
|
||||
}
|
||||
if (arg == "--project")
|
||||
project = value();
|
||||
project = path_from_utf8(value());
|
||||
else if (arg == "--engine")
|
||||
engine = value();
|
||||
engine = path_from_utf8(value());
|
||||
else if (arg == "--new")
|
||||
new_name = value();
|
||||
else if (arg == "--dimension")
|
||||
dimension = std::stoi(value());
|
||||
else if (arg == "--scene")
|
||||
scene = value();
|
||||
scene = path_from_utf8(value());
|
||||
else if (arg == "--mcp")
|
||||
mcp = true;
|
||||
else if (arg == "--gui") {
|
||||
@@ -95,7 +95,7 @@ int main(int argc, char** argv) {
|
||||
frames = std::stoull(text);
|
||||
require(frames > 0 && frames <= 10000000, "cli.frames", "Frame count out of range");
|
||||
} else if (arg == "--capture")
|
||||
capture = value();
|
||||
capture = path_from_utf8(value());
|
||||
else
|
||||
throw Error("cli.option", "Unknown option: " + arg);
|
||||
}
|
||||
@@ -130,7 +130,7 @@ int main(int argc, char** argv) {
|
||||
remember_project(project);
|
||||
#endif
|
||||
if (scene.empty())
|
||||
scene = settings.value("start_scene", std::string());
|
||||
scene = path_from_utf8(settings.value("start_scene", std::string()));
|
||||
if (!scene.empty() &&
|
||||
std::filesystem::exists(project_path(session.config().project_root, scene)))
|
||||
session.authoring().open(scene);
|
||||
@@ -203,3 +203,13 @@ int main(int argc, char** argv) {
|
||||
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/player/SceneView.hpp>
|
||||
#include <faset/runtime/Runtime.hpp>
|
||||
#include <faset/runtime/schema.hpp>
|
||||
#include <filesystem>
|
||||
#include <iostream>
|
||||
#include <set>
|
||||
@@ -167,12 +168,13 @@ void validatePackagedShaders(const std::filesystem::path& directory) {
|
||||
for (const auto* extension : {".spv", ".reflection.json"}) {
|
||||
const auto path = shaders / (std::string(entry) + extension);
|
||||
if (!std::filesystem::is_regular_file(path))
|
||||
throw std::runtime_error("Packaged shader file is missing: " + path.string());
|
||||
throw std::runtime_error("Packaged shader file is missing: " +
|
||||
faset::path_to_utf8(path));
|
||||
}
|
||||
faset::render::validate_shader_bundle(shaders);
|
||||
}
|
||||
} // namespace
|
||||
int main(int argc, char** argv) {
|
||||
int player_main(int argc, char** argv) {
|
||||
const auto started = Clock::now();
|
||||
try {
|
||||
std::filesystem::path scenePath, assetsPath, capturePath, controlPath, profilePath;
|
||||
@@ -207,15 +209,15 @@ int main(int argc, char** argv) {
|
||||
return argv[++i];
|
||||
};
|
||||
if (arg == "--scene")
|
||||
scenePath = value();
|
||||
scenePath = faset::path_from_utf8(value());
|
||||
else if (arg == "--assets")
|
||||
assetsPath = value();
|
||||
assetsPath = faset::path_from_utf8(value());
|
||||
else if (arg == "--capture")
|
||||
capturePath = value();
|
||||
capturePath = faset::path_from_utf8(value());
|
||||
else if (arg == "--control")
|
||||
controlPath = value();
|
||||
controlPath = faset::path_from_utf8(value());
|
||||
else if (arg == "--profile")
|
||||
profilePath = value();
|
||||
profilePath = faset::path_from_utf8(value());
|
||||
else if (arg == "--frames")
|
||||
maximumFrames = count(value());
|
||||
else if (arg == "--headless")
|
||||
@@ -239,11 +241,12 @@ int main(int argc, char** argv) {
|
||||
assetsPath = scenePath.parent_path();
|
||||
if (!std::filesystem::is_directory(assetsPath))
|
||||
throw std::invalid_argument("Asset cache directory does not exist: " +
|
||||
assetsPath.string());
|
||||
faset::path_to_utf8(assetsPath));
|
||||
if (headless && maximumFrames == 0)
|
||||
maximumFrames = 1;
|
||||
const auto sceneReadStarted = Clock::now();
|
||||
const auto document = faset::player::readScene(scenePath);
|
||||
faset::runtime::validate_scene_schemas(document, faset::gameplay::schema());
|
||||
const auto config = simulationConfig(document);
|
||||
const auto sceneReadFinished = Clock::now();
|
||||
const auto executableRoot = executableDirectory(argv[0]);
|
||||
@@ -406,6 +409,12 @@ int main(int argc, char** argv) {
|
||||
}
|
||||
++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 (frames == 0)
|
||||
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. "
|
||||
"GPU timestamps cover submitted rendering, not CPU work. A missing/zero GPU "
|
||||
"timestamp is null. Frame durations exclude profile bookkeeping and final "
|
||||
"capture/profile file writes. Startup begins at main(), excluding OS "
|
||||
"loader/launcher."},
|
||||
"capture/profile file writes. Startup begins at Player application entry "
|
||||
"after platform argument normalization, excluding OS loader/launcher."},
|
||||
{"startup_ms",
|
||||
{{"scene_read", milliseconds(sceneReadStarted, sceneReadFinished)},
|
||||
{"world_initialization", milliseconds(worldStarted, rendererStarted)},
|
||||
@@ -448,7 +457,7 @@ int main(int argc, char** argv) {
|
||||
faset::atomic_write_json(profilePath, report);
|
||||
}
|
||||
std::cout << nlohmann::json{{"frames", frames},
|
||||
{"ticks", world.snapshot().tick},
|
||||
{"ticks", completedTicks},
|
||||
{"dimension", document.value("dimension", 3)},
|
||||
{"device", stats.device},
|
||||
{"validation_errors", stats.validation_errors}}
|
||||
@@ -460,3 +469,13 @@ int main(int argc, char** argv) {
|
||||
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;
|
||||
fs::path user_home() {
|
||||
#ifdef _WIN32
|
||||
if (const auto* value = std::getenv("USERPROFILE"))
|
||||
if (const auto* value = _wgetenv(L"USERPROFILE"); value && *value)
|
||||
return fs::path(value);
|
||||
#else
|
||||
if (const auto* value = std::getenv("HOME"))
|
||||
@@ -24,7 +24,7 @@ fs::path user_home() {
|
||||
}
|
||||
fs::path recent_path() {
|
||||
#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";
|
||||
#else
|
||||
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.");
|
||||
if (text.find_first_of("\r\n\t") != std::string::npos || text.find('\0') != std::string::npos)
|
||||
throw std::runtime_error("A directory must fit on one line.");
|
||||
fs::path path = fs::u8path(text);
|
||||
fs::path path = path_from_utf8(text);
|
||||
if (text == "~")
|
||||
path = user_home();
|
||||
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));
|
||||
if (path.filename() == "project.faset.json")
|
||||
path = path.parent_path();
|
||||
return path;
|
||||
}
|
||||
std::string utf8(const fs::path& path) {
|
||||
const auto value = path.u8string();
|
||||
return std::string(reinterpret_cast<const char*>(value.data()), value.size());
|
||||
}
|
||||
ProjectSelection open_project(const fs::path& path) {
|
||||
if (!fs::is_directory(path))
|
||||
throw std::runtime_error("Project directory does not exist.");
|
||||
@@ -87,12 +83,12 @@ Json recent_records(const fs::path& file) {
|
||||
}
|
||||
}
|
||||
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);
|
||||
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)
|
||||
records.erase(records.end() - 1);
|
||||
atomic_write_json(store, records);
|
||||
@@ -115,6 +111,13 @@ struct ProjectLauncher::Impl {
|
||||
render::Renderer& renderer;
|
||||
ui::Context ui;
|
||||
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;
|
||||
std::optional<fs::path> pending_browse;
|
||||
std::optional<ProjectSelection> selected;
|
||||
@@ -136,7 +139,7 @@ struct ProjectLauncher::Impl {
|
||||
[this](bool enabled) { renderer.set_text_input(enabled); },
|
||||
[this](ui::Rect r) { renderer.set_text_input_area(r.x, r.y, r.width, r.height); });
|
||||
const auto start = initial.empty() ? user_home() / "FasetProjects/MyGame" : initial;
|
||||
ui.update_text("launcher-path", utf8(start));
|
||||
ui.update_text("launcher-path", path_to_utf8(start));
|
||||
create = initial.empty();
|
||||
ui.find("launcher-open")->on_click = [this](Widget&) { set_mode(false); };
|
||||
ui.find("launcher-create")->on_click = [this](Widget&) { set_mode(true); };
|
||||
@@ -151,7 +154,7 @@ struct ProjectLauncher::Impl {
|
||||
build_browser();
|
||||
load_recents();
|
||||
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");
|
||||
}
|
||||
void set_mode(bool value) {
|
||||
@@ -175,9 +178,9 @@ struct ProjectLauncher::Impl {
|
||||
const auto id = "launcher-recent-" + std::to_string(i++);
|
||||
auto& item = list.add(Kind::TreeRow, id, selection.name);
|
||||
item.layout.height = 40;
|
||||
item.tooltip = utf8(selection.path);
|
||||
item.tooltip = path_to_utf8(selection.path);
|
||||
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);
|
||||
};
|
||||
} catch (...) {
|
||||
@@ -250,7 +253,7 @@ struct ProjectLauncher::Impl {
|
||||
actions.layout.height = 36;
|
||||
button(actions, "browser-choose", "Choose directory", [this] {
|
||||
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();
|
||||
}
|
||||
}).layout.width = 180;
|
||||
@@ -277,11 +280,11 @@ struct ProjectLauncher::Impl {
|
||||
auto& list = *ui.find("browser-list");
|
||||
list.children.clear();
|
||||
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;
|
||||
for (const auto& child : children) {
|
||||
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.on_click = [this, child](Widget&) {
|
||||
ui.clear_focus();
|
||||
@@ -327,6 +330,7 @@ struct ProjectLauncher::Impl {
|
||||
}
|
||||
}
|
||||
void refresh() {
|
||||
ui_scale = std::clamp(renderer.display_scale(), .5f, 4.f);
|
||||
ui.find("launcher-open")->selected = !create;
|
||||
ui.find("launcher-create")->selected = create;
|
||||
ui.find("launcher-2d")->selected = dimension == 2;
|
||||
@@ -344,17 +348,17 @@ struct ProjectLauncher::Impl {
|
||||
ui.find("launcher-body")->enabled = !browsing;
|
||||
auto* dialog = ui.find("launcher-browser");
|
||||
dialog->visible = browsing;
|
||||
dialog->layout.width = std::max(320.f, std::min(760.f, float(renderer.width()) - 40));
|
||||
dialog->layout.height = std::max(300.f, std::min(540.f, float(renderer.height()) - 40));
|
||||
dialog->layout.x = (float(renderer.width()) - dialog->layout.width) * .5f;
|
||||
dialog->layout.y = (float(renderer.height()) - dialog->layout.height) * .5f;
|
||||
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, logical_height() - 40));
|
||||
dialog->layout.x = (logical_width() - dialog->layout.width) * .5f;
|
||||
dialog->layout.y = (logical_height() - dialog->layout.height) * .5f;
|
||||
ui.find("launcher-sidebar")->layout.width =
|
||||
std::clamp(float(renderer.width()) * .24f, 180.f, 235.f);
|
||||
ui.find("launcher-main")->layout.padding = renderer.width() < 850 ? 16 : 32;
|
||||
std::clamp(logical_width() * .24f, 180.f, 235.f);
|
||||
ui.find("launcher-main")->layout.padding = logical_width() < 850 ? 16 : 32;
|
||||
}
|
||||
void frame(const std::vector<render::Event>& events) {
|
||||
refresh();
|
||||
ui.layout(float(renderer.width()), float(renderer.height()));
|
||||
ui.layout(float(renderer.width()), float(renderer.height()), ui_scale);
|
||||
for (const auto& event : events) {
|
||||
if (event.type == render::Event::Type::Quit)
|
||||
cancelled = true;
|
||||
@@ -371,7 +375,7 @@ struct ProjectLauncher::Impl {
|
||||
ui.clear_focus();
|
||||
navigate_pending();
|
||||
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();
|
||||
}
|
||||
} else
|
||||
@@ -384,10 +388,10 @@ struct ProjectLauncher::Impl {
|
||||
ui.handle(event);
|
||||
navigate_pending();
|
||||
refresh();
|
||||
ui.layout(float(renderer.width()), float(renderer.height()));
|
||||
ui.layout(float(renderer.width()), float(renderer.height()), ui_scale);
|
||||
}
|
||||
refresh();
|
||||
ui.layout(float(renderer.width()), float(renderer.height()));
|
||||
ui.layout(float(renderer.width()), float(renderer.height()), ui_scale);
|
||||
frame_data = {};
|
||||
frame_data.clear_color = ui.theme().background;
|
||||
ui.draw(frame_data);
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
#include <iostream>
|
||||
#include <stdexcept>
|
||||
|
||||
int main(int argc, char** argv) {
|
||||
int schema_main(int argc, char** argv) {
|
||||
try {
|
||||
std::filesystem::path output;
|
||||
for (int i = 1; i < argc; ++i) {
|
||||
@@ -14,7 +14,7 @@ int main(int argc, char** argv) {
|
||||
return 0;
|
||||
}
|
||||
if (argument == "--output" && i + 1 < argc && output.empty())
|
||||
output = argv[++i];
|
||||
output = faset::path_from_utf8(argv[++i]);
|
||||
else
|
||||
throw std::invalid_argument("Unknown, repeated or incomplete argument: " +
|
||||
argument);
|
||||
@@ -33,3 +33,13 @@ int main(int argc, char** argv) {
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user