From 8a02d4985a7b4504098b109b15be20df2fdcdde5 Mon Sep 17 00:00:00 2001 From: johndoe6345789 Date: Fri, 9 Jan 2026 21:54:32 +0000 Subject: [PATCH] ROADMAP.md --- CMakeLists.txt | 2 + ROADMAP.md | 4 +- config/workflows/templates/boot_default.json | 11 + config/workflows/templates/frame_default.json | 42 + src/services/impl/json_config_service.cpp | 1035 +---------------- src/services/impl/runtime_config_builder.cpp | 1030 ++++++++++++++++ src/services/impl/runtime_config_builder.hpp | 24 + .../impl/workflow_config_pipeline.cpp | 20 +- .../impl/workflow_config_pipeline.hpp | 11 +- .../impl/workflow_default_step_registrar.cpp | 4 + .../impl/workflow_frame_begin_step.cpp | 34 + .../impl/workflow_frame_begin_step.hpp | 21 + .../impl/workflow_frame_physics_step.cpp | 35 + .../impl/workflow_frame_physics_step.hpp | 24 + .../impl/workflow_runtime_config_step.cpp | 48 + .../impl/workflow_runtime_config_step.hpp | 20 + 16 files changed, 1324 insertions(+), 1041 deletions(-) create mode 100644 config/workflows/templates/frame_default.json create mode 100644 src/services/impl/runtime_config_builder.cpp create mode 100644 src/services/impl/runtime_config_builder.hpp create mode 100644 src/services/impl/workflow_frame_begin_step.cpp create mode 100644 src/services/impl/workflow_frame_begin_step.hpp create mode 100644 src/services/impl/workflow_frame_physics_step.cpp create mode 100644 src/services/impl/workflow_frame_physics_step.hpp create mode 100644 src/services/impl/workflow_runtime_config_step.cpp create mode 100644 src/services/impl/workflow_runtime_config_step.hpp diff --git a/CMakeLists.txt b/CMakeLists.txt index fca3220..93b29f8 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -274,6 +274,7 @@ endif() set(JSON_CONFIG_SOURCES src/services/impl/json_config_service.cpp + src/services/impl/runtime_config_builder.cpp src/services/impl/json_config_document_loader.cpp src/services/impl/json_config_document_parser.cpp src/services/impl/json_config_extend_resolver.cpp @@ -296,6 +297,7 @@ set(WORKFLOW_SOURCES src/services/impl/workflow_config_migration_step.cpp src/services/impl/workflow_config_schema_step.cpp src/services/impl/workflow_default_step_registrar.cpp + src/services/impl/workflow_runtime_config_step.cpp ) set(MATERIALX_SCRIPT_SOURCES diff --git a/ROADMAP.md b/ROADMAP.md index df512d6..95e7a23 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -242,8 +242,8 @@ Option B: per-shader only - [x] Template package: `config/workflows/templates/boot_default.json`. ### Next Steps -- Move RuntimeConfig parsing into a workflow step. -- Add frame workflow template (BeginFrame → RenderGraph → Capture → Validate). +- [x] Move RuntimeConfig parsing into a workflow step. +- [ ] Add frame workflow template (BeginFrame → RenderGraph → Capture → Validate). ## Feature Matrix (What You Get, When You Get It) diff --git a/config/workflows/templates/boot_default.json b/config/workflows/templates/boot_default.json index 1d058d0..982e550 100644 --- a/config/workflows/templates/boot_default.json +++ b/config/workflows/templates/boot_default.json @@ -42,6 +42,17 @@ "document": "config.document", "path": "config.path" } + }, + { + "id": "build_runtime_config", + "plugin": "runtime.config.build", + "inputs": { + "document": "config.document", + "path": "config.path" + }, + "outputs": { + "runtime": "config.runtime" + } } ] } diff --git a/config/workflows/templates/frame_default.json b/config/workflows/templates/frame_default.json new file mode 100644 index 0000000..354a377 --- /dev/null +++ b/config/workflows/templates/frame_default.json @@ -0,0 +1,42 @@ +{ + "template": "frame.default", + "steps": [ + { + "id": "begin_frame", + "plugin": "frame.begin", + "inputs": { + "delta": "frame.delta", + "elapsed": "frame.elapsed" + } + }, + { + "id": "step_physics", + "plugin": "frame.physics", + "inputs": { + "delta": "frame.delta" + } + }, + { + "id": "update_scene", + "plugin": "frame.scene", + "inputs": { + "delta": "frame.delta" + } + }, + { + "id": "render_frame", + "plugin": "frame.render", + "inputs": { + "elapsed": "frame.elapsed" + } + }, + { + "id": "update_audio", + "plugin": "frame.audio" + }, + { + "id": "dispatch_gui", + "plugin": "frame.gui" + } + ] +} diff --git a/src/services/impl/json_config_service.cpp b/src/services/impl/json_config_service.cpp index ee89b91..8877095 100644 --- a/src/services/impl/json_config_service.cpp +++ b/src/services/impl/json_config_service.cpp @@ -8,7 +8,6 @@ #include #include #include -#include #include namespace sdl3cpp::services::impl { @@ -24,16 +23,6 @@ const char* SceneSourceName(SceneSource source) { return "config"; } } - -SceneSource ParseSceneSource(const std::string& value, const std::string& jsonPath) { - if (value == "config") { - return SceneSource::Config; - } - if (value == "lua") { - return SceneSource::Lua; - } - throw std::runtime_error("JSON member '" + jsonPath + "' must be 'config' or 'lua'"); -} } // namespace JsonConfigService::JsonConfigService(std::shared_ptr logger, @@ -118,12 +107,12 @@ RuntimeConfig JsonConfigService::LoadFromJson(std::shared_ptr logger, logger->Trace("JsonConfigService", "LoadFromJson", args); WorkflowConfigPipeline pipeline(logger, probeService); - std::shared_ptr documentHandle = pipeline.Execute(configPath, nullptr); - if (!documentHandle) { + WorkflowResult result = pipeline.Execute(configPath, nullptr); + if (!result.document) { throw std::runtime_error("JsonConfigService::LoadFromJson: workflow pipeline returned null document"); } - const rapidjson::Document& document = *documentHandle; + const rapidjson::Document& document = *result.document; if (dumpConfig || configJson) { rapidjson::StringBuffer buffer; rapidjson::PrettyWriter writer(buffer); @@ -138,1022 +127,12 @@ RuntimeConfig JsonConfigService::LoadFromJson(std::shared_ptr logger, } } - auto getObjectMember = [&](const rapidjson::Value& parent, - const char* name, - const char* fullName) -> const rapidjson::Value* { - if (!parent.HasMember(name)) { - return nullptr; - } - const auto& value = parent[name]; - if (!value.IsObject()) { - throw std::runtime_error("JSON member '" + std::string(fullName) + "' must be an object"); - } - return &value; - }; - - const auto* scriptsValue = getObjectMember(document, "scripts", "scripts"); - const auto* pathsValue = getObjectMember(document, "paths", "paths"); - const auto* windowValue = getObjectMember(document, "window", "window"); - const auto* windowSizeValue = windowValue - ? getObjectMember(*windowValue, "size", "window.size") - : nullptr; - const auto* runtimeValue = getObjectMember(document, "runtime", "runtime"); - const auto* inputValue = getObjectMember(document, "input", "input"); - const auto* inputBindingsValue = inputValue - ? getObjectMember(*inputValue, "bindings", "input.bindings") - : nullptr; - const auto* renderingValue = getObjectMember(document, "rendering", "rendering"); - const auto* guiValue = getObjectMember(document, "gui", "gui"); - - std::optional scriptPathValue; - if (scriptsValue && scriptsValue->HasMember("entry")) { - const auto& value = (*scriptsValue)["entry"]; - if (!value.IsString()) { - throw std::runtime_error("JSON member 'scripts.entry' must be a string"); - } - scriptPathValue = value.GetString(); - } else if (document.HasMember("lua_script")) { - const auto& value = document["lua_script"]; - if (!value.IsString()) { - throw std::runtime_error("JSON member 'lua_script' must be a string"); - } - scriptPathValue = value.GetString(); - } - if (!scriptPathValue) { - throw std::runtime_error("JSON config requires a string member 'scripts.entry' or 'lua_script'"); + const auto* runtimeConfig = result.context.TryGet("config.runtime"); + if (!runtimeConfig) { + throw std::runtime_error("JsonConfigService::LoadFromJson: workflow did not produce runtime config"); } - std::optional projectRoot; - if (pathsValue && pathsValue->HasMember("project_root")) { - const auto& value = (*pathsValue)["project_root"]; - if (!value.IsString()) { - throw std::runtime_error("JSON member 'paths.project_root' must be a string"); - } - std::filesystem::path candidate(value.GetString()); - if (candidate.is_absolute()) { - projectRoot = std::filesystem::weakly_canonical(candidate); - } else { - projectRoot = std::filesystem::weakly_canonical(configPath.parent_path() / candidate); - } - } else if (document.HasMember("project_root")) { - const auto& value = document["project_root"]; - if (!value.IsString()) { - throw std::runtime_error("JSON member 'project_root' must be a string"); - } - std::filesystem::path candidate(value.GetString()); - if (candidate.is_absolute()) { - projectRoot = std::filesystem::weakly_canonical(candidate); - } else { - projectRoot = std::filesystem::weakly_canonical(configPath.parent_path() / candidate); - } - } - - RuntimeConfig config; - std::filesystem::path scriptPath(*scriptPathValue); - if (!scriptPath.is_absolute()) { - if (projectRoot) { - scriptPath = *projectRoot / scriptPath; - } else { - scriptPath = configPath.parent_path() / scriptPath; - } - } - scriptPath = std::filesystem::weakly_canonical(scriptPath); - if (!std::filesystem::exists(scriptPath)) { - throw std::runtime_error("Lua script not found at " + scriptPath.string()); - } - config.scriptPath = scriptPath; - - if (runtimeValue && runtimeValue->HasMember("scene_source")) { - const auto& value = (*runtimeValue)["scene_source"]; - if (!value.IsString()) { - throw std::runtime_error("JSON member 'runtime.scene_source' must be a string"); - } - config.sceneSource = ParseSceneSource(value.GetString(), "runtime.scene_source"); - } - - auto parseDimension = [&](const char* name, uint32_t defaultValue) -> uint32_t { - if (!document.HasMember(name)) { - return defaultValue; - } - const auto& value = document[name]; - if (value.IsUint()) { - return value.GetUint(); - } - if (value.IsInt()) { - int maybeValue = value.GetInt(); - if (maybeValue >= 0) { - return static_cast(maybeValue); - } - } - throw std::runtime_error(std::string("JSON member '") + name + "' must be a non-negative integer"); - }; - - auto parseDimensionValue = [&](const rapidjson::Value& value, const char* name) -> uint32_t { - if (value.IsUint()) { - return value.GetUint(); - } - if (value.IsInt()) { - int maybeValue = value.GetInt(); - if (maybeValue >= 0) { - return static_cast(maybeValue); - } - } - throw std::runtime_error(std::string("JSON member '") + name + "' must be a non-negative integer"); - }; - - if (windowSizeValue) { - if (windowSizeValue->HasMember("width")) { - config.width = parseDimensionValue((*windowSizeValue)["width"], "window.size.width"); - } - if (windowSizeValue->HasMember("height")) { - config.height = parseDimensionValue((*windowSizeValue)["height"], "window.size.height"); - } - } else { - config.width = parseDimension("window_width", config.width); - config.height = parseDimension("window_height", config.height); - } - - if (scriptsValue && scriptsValue->HasMember("lua_debug")) { - const auto& value = (*scriptsValue)["lua_debug"]; - if (!value.IsBool()) { - throw std::runtime_error("JSON member 'scripts.lua_debug' must be a boolean"); - } - config.luaDebug = value.GetBool(); - } else if (document.HasMember("lua_debug")) { - const auto& value = document["lua_debug"]; - if (!value.IsBool()) { - throw std::runtime_error("JSON member 'lua_debug' must be a boolean"); - } - config.luaDebug = value.GetBool(); - } - - if (windowValue && windowValue->HasMember("title")) { - const auto& value = (*windowValue)["title"]; - if (!value.IsString()) { - throw std::runtime_error("JSON member 'window.title' must be a string"); - } - config.windowTitle = value.GetString(); - } else if (document.HasMember("window_title")) { - const auto& value = document["window_title"]; - if (!value.IsString()) { - throw std::runtime_error("JSON member 'window_title' must be a string"); - } - config.windowTitle = value.GetString(); - } - - const rapidjson::Value* mouseGrabValue = nullptr; - std::string mouseGrabPath = "mouse_grab"; - if (windowValue && windowValue->HasMember("mouse_grab")) { - const auto& value = (*windowValue)["mouse_grab"]; - if (!value.IsObject()) { - throw std::runtime_error("JSON member 'window.mouse_grab' must be an object"); - } - mouseGrabValue = &value; - mouseGrabPath = "window.mouse_grab"; - } else if (document.HasMember("mouse_grab")) { - const auto& value = document["mouse_grab"]; - if (!value.IsObject()) { - throw std::runtime_error("JSON member 'mouse_grab' must be an object"); - } - mouseGrabValue = &value; - } - if (mouseGrabValue) { - auto readBool = [&](const char* name, bool& target) { - if (!mouseGrabValue->HasMember(name)) { - return; - } - const auto& value = (*mouseGrabValue)[name]; - if (!value.IsBool()) { - throw std::runtime_error("JSON member '" + mouseGrabPath + "." + std::string(name) + "' must be a boolean"); - } - target = value.GetBool(); - }; - auto readString = [&](const char* name, std::string& target) { - if (!mouseGrabValue->HasMember(name)) { - return; - } - const auto& value = (*mouseGrabValue)[name]; - if (!value.IsString()) { - throw std::runtime_error("JSON member '" + mouseGrabPath + "." + std::string(name) + "' must be a string"); - } - target = value.GetString(); - }; - readBool("enabled", config.mouseGrab.enabled); - readBool("grab_on_click", config.mouseGrab.grabOnClick); - readBool("release_on_escape", config.mouseGrab.releaseOnEscape); - readBool("start_grabbed", config.mouseGrab.startGrabbed); - readBool("hide_cursor", config.mouseGrab.hideCursor); - readBool("relative_mode", config.mouseGrab.relativeMode); - readString("grab_mouse_button", config.mouseGrab.grabMouseButton); - readString("release_key", config.mouseGrab.releaseKey); - } - - const rapidjson::Value* bindingsValue = nullptr; - std::string bindingsPath = "input_bindings"; - if (inputBindingsValue) { - bindingsValue = inputBindingsValue; - bindingsPath = "input.bindings"; - } else if (document.HasMember("input_bindings")) { - const auto& value = document["input_bindings"]; - if (!value.IsObject()) { - throw std::runtime_error("JSON member 'input_bindings' must be an object"); - } - bindingsValue = &value; - } - if (bindingsValue) { - struct BindingSpec { - const char* name; - std::string InputBindings::* member; - }; - const std::array bindingSpecs = {{ - {"move_forward", &InputBindings::moveForwardKey}, - {"move_back", &InputBindings::moveBackKey}, - {"move_left", &InputBindings::moveLeftKey}, - {"move_right", &InputBindings::moveRightKey}, - {"fly_up", &InputBindings::flyUpKey}, - {"fly_down", &InputBindings::flyDownKey}, - {"jump", &InputBindings::jumpKey}, - {"noclip_toggle", &InputBindings::noclipToggleKey}, - {"music_toggle", &InputBindings::musicToggleKey}, - {"music_toggle_gamepad", &InputBindings::musicToggleGamepadButton}, - {"gamepad_move_x_axis", &InputBindings::gamepadMoveXAxis}, - {"gamepad_move_y_axis", &InputBindings::gamepadMoveYAxis}, - {"gamepad_look_x_axis", &InputBindings::gamepadLookXAxis}, - {"gamepad_look_y_axis", &InputBindings::gamepadLookYAxis}, - {"gamepad_dpad_up", &InputBindings::gamepadDpadUpButton}, - {"gamepad_dpad_down", &InputBindings::gamepadDpadDownButton}, - {"gamepad_dpad_left", &InputBindings::gamepadDpadLeftButton}, - {"gamepad_dpad_right", &InputBindings::gamepadDpadRightButton}, - }}; - - auto readBinding = [&](const BindingSpec& spec) { - if (!bindingsValue->HasMember(spec.name)) { - return; - } - const auto& value = (*bindingsValue)[spec.name]; - if (!value.IsString()) { - throw std::runtime_error("JSON member '" + bindingsPath + "." + std::string(spec.name) + "' must be a string"); - } - config.inputBindings.*(spec.member) = value.GetString(); - }; - - for (const auto& spec : bindingSpecs) { - readBinding(spec); - } - - auto readMapping = [&](const char* name, - std::unordered_map& target) { - if (!bindingsValue->HasMember(name)) { - return; - } - const auto& mappingValue = (*bindingsValue)[name]; - if (!mappingValue.IsObject()) { - throw std::runtime_error("JSON member '" + bindingsPath + "." + std::string(name) + "' must be an object"); - } - for (auto it = mappingValue.MemberBegin(); it != mappingValue.MemberEnd(); ++it) { - if (!it->name.IsString() || !it->value.IsString()) { - throw std::runtime_error("JSON member '" + bindingsPath + "." + std::string(name) + - "' must contain string pairs"); - } - std::string key = it->name.GetString(); - std::string value = it->value.GetString(); - if (value.empty()) { - target.erase(key); - } else { - target[key] = value; - } - } - }; - - readMapping("gamepad_button_actions", config.inputBindings.gamepadButtonActions); - readMapping("gamepad_axis_actions", config.inputBindings.gamepadAxisActions); - - if (bindingsValue->HasMember("gamepad_axis_action_threshold")) { - const auto& value = (*bindingsValue)["gamepad_axis_action_threshold"]; - if (!value.IsNumber()) { - throw std::runtime_error("JSON member '" + bindingsPath + ".gamepad_axis_action_threshold' must be a number"); - } - config.inputBindings.gamepadAxisActionThreshold = static_cast(value.GetDouble()); - } - } - - const rapidjson::Value* atmosphericsValue = nullptr; - std::string atmosphericsPath = "atmospherics"; - if (renderingValue) { - atmosphericsValue = getObjectMember(*renderingValue, "atmospherics", "rendering.atmospherics"); - if (atmosphericsValue) { - atmosphericsPath = "rendering.atmospherics"; - } - } - if (!atmosphericsValue && document.HasMember("atmospherics")) { - const auto& value = document["atmospherics"]; - if (!value.IsObject()) { - throw std::runtime_error("JSON member 'atmospherics' must be an object"); - } - atmosphericsValue = &value; - } - if (atmosphericsValue) { - - auto readFloat = [&](const char* name, float& target) { - if (!atmosphericsValue->HasMember(name)) { - return; - } - const auto& value = (*atmosphericsValue)[name]; - if (!value.IsNumber()) { - throw std::runtime_error("JSON member '" + atmosphericsPath + "." + std::string(name) + - "' must be a number"); - } - target = static_cast(value.GetDouble()); - }; - - auto readBool = [&](const char* name, bool& target) { - if (!atmosphericsValue->HasMember(name)) { - return; - } - const auto& value = (*atmosphericsValue)[name]; - if (!value.IsBool()) { - throw std::runtime_error("JSON member '" + atmosphericsPath + "." + std::string(name) + - "' must be a boolean"); - } - target = value.GetBool(); - }; - - auto readFloatArray3 = [&](const char* name, std::array& target) { - if (!atmosphericsValue->HasMember(name)) { - return; - } - const auto& value = (*atmosphericsValue)[name]; - if (!value.IsArray() || value.Size() != 3) { - throw std::runtime_error("JSON member '" + atmosphericsPath + "." + std::string(name) + - "' must be an array of 3 numbers"); - } - for (rapidjson::SizeType i = 0; i < 3; ++i) { - if (!value[i].IsNumber()) { - throw std::runtime_error("JSON member '" + atmosphericsPath + "." + std::string(name) + - "[" + std::to_string(i) + "]' must be a number"); - } - target[i] = static_cast(value[i].GetDouble()); - } - }; - - readFloat("ambient_strength", config.atmospherics.ambientStrength); - readFloat("fog_density", config.atmospherics.fogDensity); - readFloatArray3("fog_color", config.atmospherics.fogColor); - readFloatArray3("sky_color", config.atmospherics.skyColor); - readFloat("gamma", config.atmospherics.gamma); - readFloat("exposure", config.atmospherics.exposure); - readBool("enable_tone_mapping", config.atmospherics.enableToneMapping); - readBool("enable_shadows", config.atmospherics.enableShadows); - readBool("enable_ssgi", config.atmospherics.enableSSGI); - readBool("enable_volumetric_lighting", config.atmospherics.enableVolumetricLighting); - readFloat("pbr_roughness", config.atmospherics.pbrRoughness); - readFloat("pbr_metallic", config.atmospherics.pbrMetallic); - } - - const rapidjson::Value* bgfxValue = nullptr; - std::string bgfxPath = "bgfx"; - if (renderingValue) { - bgfxValue = getObjectMember(*renderingValue, "bgfx", "rendering.bgfx"); - if (bgfxValue) { - bgfxPath = "rendering.bgfx"; - } - } - if (!bgfxValue && document.HasMember("bgfx")) { - const auto& value = document["bgfx"]; - if (!value.IsObject()) { - throw std::runtime_error("JSON member 'bgfx' must be an object"); - } - bgfxValue = &value; - } - if (bgfxValue) { - if (bgfxValue->HasMember("renderer")) { - const auto& value = (*bgfxValue)["renderer"]; - if (!value.IsString()) { - throw std::runtime_error("JSON member '" + bgfxPath + ".renderer' must be a string"); - } - config.bgfx.renderer = value.GetString(); - } - } - - const rapidjson::Value* materialValue = nullptr; - std::string materialPath = "materialx"; - if (renderingValue) { - materialValue = getObjectMember(*renderingValue, "materialx", "rendering.materialx"); - if (materialValue) { - materialPath = "rendering.materialx"; - } - } - if (!materialValue && document.HasMember("materialx")) { - const auto& value = document["materialx"]; - if (!value.IsObject()) { - throw std::runtime_error("JSON member 'materialx' must be an object"); - } - materialValue = &value; - } - - bool materialShaderKeyProvided = false; - if (materialValue) { - if (materialValue->HasMember("enabled")) { - const auto& value = (*materialValue)["enabled"]; - if (!value.IsBool()) { - throw std::runtime_error("JSON member '" + materialPath + ".enabled' must be a boolean"); - } - config.materialX.enabled = value.GetBool(); - } - if (materialValue->HasMember("document")) { - const auto& value = (*materialValue)["document"]; - if (!value.IsString()) { - throw std::runtime_error("JSON member '" + materialPath + ".document' must be a string"); - } - config.materialX.documentPath = value.GetString(); - } - if (materialValue->HasMember("shader_key")) { - const auto& value = (*materialValue)["shader_key"]; - if (!value.IsString()) { - throw std::runtime_error("JSON member '" + materialPath + ".shader_key' must be a string"); - } - config.materialX.shaderKey = value.GetString(); - materialShaderKeyProvided = true; - } - if (materialValue->HasMember("material")) { - const auto& value = (*materialValue)["material"]; - if (!value.IsString()) { - throw std::runtime_error("JSON member '" + materialPath + ".material' must be a string"); - } - config.materialX.materialName = value.GetString(); - } - if (materialValue->HasMember("library_path")) { - const auto& value = (*materialValue)["library_path"]; - if (!value.IsString()) { - throw std::runtime_error("JSON member '" + materialPath + ".library_path' must be a string"); - } - config.materialX.libraryPath = value.GetString(); - } - if (materialValue->HasMember("library_folders")) { - const auto& value = (*materialValue)["library_folders"]; - if (!value.IsArray()) { - throw std::runtime_error("JSON member '" + materialPath + ".library_folders' must be an array"); - } - config.materialX.libraryFolders.clear(); - for (rapidjson::SizeType i = 0; i < value.Size(); ++i) { - if (!value[i].IsString()) { - throw std::runtime_error("JSON member '" + materialPath + ".library_folders[" + - std::to_string(i) + "]' must be a string"); - } - config.materialX.libraryFolders.emplace_back(value[i].GetString()); - } - } - if (materialValue->HasMember("use_constant_color")) { - const auto& value = (*materialValue)["use_constant_color"]; - if (!value.IsBool()) { - throw std::runtime_error("JSON member '" + materialPath + ".use_constant_color' must be a boolean"); - } - config.materialX.useConstantColor = value.GetBool(); - } - if (materialValue->HasMember("constant_color")) { - const auto& value = (*materialValue)["constant_color"]; - if (!value.IsArray() || value.Size() != 3) { - throw std::runtime_error("JSON member '" + materialPath + ".constant_color' must be an array of 3 numbers"); - } - for (rapidjson::SizeType i = 0; i < 3; ++i) { - if (!value[i].IsNumber()) { - throw std::runtime_error("JSON member '" + materialPath + ".constant_color[" + - std::to_string(i) + "]' must be a number"); - } - config.materialX.constantColor[i] = static_cast(value[i].GetDouble()); - } - } - } - - const rapidjson::Value* materialsValue = nullptr; - std::string materialsPath = "materialx_materials"; - if (materialValue && materialValue->HasMember("materials")) { - const auto& value = (*materialValue)["materials"]; - if (!value.IsArray()) { - throw std::runtime_error("JSON member '" + materialPath + ".materials' must be an array"); - } - materialsValue = &value; - materialsPath = materialPath + ".materials"; - } - if (!materialsValue && document.HasMember("materialx_materials")) { - const auto& value = document["materialx_materials"]; - if (!value.IsArray()) { - throw std::runtime_error("JSON member 'materialx_materials' must be an array"); - } - materialsValue = &value; - } - if (materialsValue) { - config.materialXMaterials.clear(); - for (rapidjson::SizeType i = 0; i < materialsValue->Size(); ++i) { - const auto& entry = (*materialsValue)[i]; - if (!entry.IsObject()) { - throw std::runtime_error("JSON member '" + materialsPath + "[" + std::to_string(i) + - "]' must be an object"); - } - MaterialXMaterialConfig materialConfig; - if (entry.HasMember("enabled")) { - const auto& value = entry["enabled"]; - if (!value.IsBool()) { - throw std::runtime_error("JSON member '" + materialsPath + "[" + std::to_string(i) + - "].enabled' must be a boolean"); - } - materialConfig.enabled = value.GetBool(); - } - if (entry.HasMember("document")) { - const auto& value = entry["document"]; - if (!value.IsString()) { - throw std::runtime_error("JSON member '" + materialsPath + "[" + std::to_string(i) + - "].document' must be a string"); - } - materialConfig.documentPath = value.GetString(); - } - if (entry.HasMember("shader_key")) { - const auto& value = entry["shader_key"]; - if (!value.IsString()) { - throw std::runtime_error("JSON member '" + materialsPath + "[" + std::to_string(i) + - "].shader_key' must be a string"); - } - materialConfig.shaderKey = value.GetString(); - } - if (entry.HasMember("material")) { - const auto& value = entry["material"]; - if (!value.IsString()) { - throw std::runtime_error("JSON member '" + materialsPath + "[" + std::to_string(i) + - "].material' must be a string"); - } - materialConfig.materialName = value.GetString(); - } - if (entry.HasMember("use_constant_color")) { - const auto& value = entry["use_constant_color"]; - if (!value.IsBool()) { - throw std::runtime_error("JSON member '" + materialsPath + "[" + std::to_string(i) + - "].use_constant_color' must be a boolean"); - } - materialConfig.useConstantColor = value.GetBool(); - } - if (entry.HasMember("constant_color")) { - const auto& value = entry["constant_color"]; - if (!value.IsArray() || value.Size() != 3) { - throw std::runtime_error("JSON member '" + materialsPath + "[" + std::to_string(i) + - "].constant_color' must be an array of 3 numbers"); - } - for (rapidjson::SizeType channel = 0; channel < 3; ++channel) { - if (!value[channel].IsNumber()) { - throw std::runtime_error("JSON member '" + materialsPath + "[" + std::to_string(i) + - "].constant_color[" + std::to_string(channel) + - "]' must be a number"); - } - materialConfig.constantColor[channel] = static_cast(value[channel].GetDouble()); - } - } - - if (materialConfig.shaderKey.empty()) { - throw std::runtime_error("JSON member '" + materialsPath + "[" + std::to_string(i) + - "].shader_key' must be provided"); - } - if (materialConfig.documentPath.empty() && !materialConfig.useConstantColor) { - throw std::runtime_error("JSON member '" + materialsPath + "[" + std::to_string(i) + - "].document' is required when use_constant_color is false"); - } - - config.materialXMaterials.push_back(std::move(materialConfig)); - } - } - - if (!materialShaderKeyProvided && !config.materialXMaterials.empty()) { - config.materialX.shaderKey = config.materialXMaterials.front().shaderKey; - if (logger) { - logger->Trace("JsonConfigService", "LoadFromJson", - "materialx.shader_key not set; defaulting to first materialx_materials key=" + - config.materialX.shaderKey); - } - } - - const rapidjson::Value* guiFontValue = nullptr; - std::string guiFontPath = "gui_font"; - if (guiValue && guiValue->HasMember("font")) { - const auto& value = (*guiValue)["font"]; - if (!value.IsObject()) { - throw std::runtime_error("JSON member 'gui.font' must be an object"); - } - guiFontValue = &value; - guiFontPath = "gui.font"; - } else if (document.HasMember("gui_font")) { - const auto& value = document["gui_font"]; - if (!value.IsObject()) { - throw std::runtime_error("JSON member 'gui_font' must be an object"); - } - guiFontValue = &value; - } - if (guiFontValue) { - if (guiFontValue->HasMember("use_freetype")) { - const auto& value = (*guiFontValue)["use_freetype"]; - if (!value.IsBool()) { - throw std::runtime_error("JSON member '" + guiFontPath + ".use_freetype' must be a boolean"); - } - config.guiFont.useFreeType = value.GetBool(); - } - if (guiFontValue->HasMember("font_path")) { - const auto& value = (*guiFontValue)["font_path"]; - if (!value.IsString()) { - throw std::runtime_error("JSON member '" + guiFontPath + ".font_path' must be a string"); - } - config.guiFont.fontPath = value.GetString(); - } - if (guiFontValue->HasMember("font_size")) { - const auto& value = (*guiFontValue)["font_size"]; - if (!value.IsNumber()) { - throw std::runtime_error("JSON member '" + guiFontPath + ".font_size' must be a number"); - } - config.guiFont.fontSize = static_cast(value.GetDouble()); - } - } - - if (guiValue && guiValue->HasMember("opacity")) { - const auto& value = (*guiValue)["opacity"]; - if (!value.IsNumber()) { - throw std::runtime_error("JSON member 'gui.opacity' must be a number"); - } - config.guiOpacity = static_cast(value.GetDouble()); - } else if (document.HasMember("gui_opacity")) { - const auto& value = document["gui_opacity"]; - if (!value.IsNumber()) { - throw std::runtime_error("JSON member 'gui_opacity' must be a number"); - } - config.guiOpacity = static_cast(value.GetDouble()); - } - - auto readSizeT = [](const rapidjson::Value& parent, - const char* name, - const std::string& path, - size_t& target) { - if (!parent.HasMember(name)) { - return; - } - const auto& value = parent[name]; - if (!value.IsNumber()) { - throw std::runtime_error("JSON member '" + path + "." + std::string(name) + "' must be a number"); - } - const double rawValue = value.GetDouble(); - if (rawValue < 0.0) { - throw std::runtime_error("JSON member '" + path + "." + std::string(name) + "' must be non-negative"); - } - target = static_cast(rawValue); - }; - - auto readUint32 = [](const rapidjson::Value& parent, - const char* name, - const std::string& path, - uint32_t& target) { - if (!parent.HasMember(name)) { - return; - } - const auto& value = parent[name]; - if (!value.IsNumber()) { - throw std::runtime_error("JSON member '" + path + "." + std::string(name) + "' must be a number"); - } - const double rawValue = value.GetDouble(); - if (rawValue < 0.0) { - throw std::runtime_error("JSON member '" + path + "." + std::string(name) + "' must be non-negative"); - } - target = static_cast(rawValue); - }; - - const auto* budgetsValue = getObjectMember(document, "budgets", "budgets"); - if (budgetsValue) { - readSizeT(*budgetsValue, "vram_mb", "budgets", config.budgets.vramMB); - readUint32(*budgetsValue, "max_texture_dim", "budgets", config.budgets.maxTextureDim); - readSizeT(*budgetsValue, "gui_text_cache_entries", "budgets", config.budgets.guiTextCacheEntries); - readSizeT(*budgetsValue, "gui_svg_cache_entries", "budgets", config.budgets.guiSvgCacheEntries); - } - - const auto* crashRecoveryValue = getObjectMember(document, "crash_recovery", "crash_recovery"); - if (crashRecoveryValue) { - readUint32(*crashRecoveryValue, "heartbeat_timeout_ms", - "crash_recovery", config.crashRecovery.heartbeatTimeoutMs); - readUint32(*crashRecoveryValue, "heartbeat_poll_interval_ms", - "crash_recovery", config.crashRecovery.heartbeatPollIntervalMs); - readSizeT(*crashRecoveryValue, "memory_limit_mb", - "crash_recovery", config.crashRecovery.memoryLimitMB); - readSizeT(*crashRecoveryValue, "max_consecutive_gpu_timeouts", - "crash_recovery", config.crashRecovery.maxConsecutiveGpuTimeouts); - readSizeT(*crashRecoveryValue, "max_lua_failures", - "crash_recovery", config.crashRecovery.maxLuaFailures); - readSizeT(*crashRecoveryValue, "max_file_format_errors", - "crash_recovery", config.crashRecovery.maxFileFormatErrors); - readSizeT(*crashRecoveryValue, "max_memory_warnings", - "crash_recovery", config.crashRecovery.maxMemoryWarnings); - if (crashRecoveryValue->HasMember("gpu_hang_frame_time_multiplier")) { - const auto& value = (*crashRecoveryValue)["gpu_hang_frame_time_multiplier"]; - if (!value.IsNumber()) { - throw std::runtime_error("JSON member 'crash_recovery.gpu_hang_frame_time_multiplier' must be a number"); - } - const double rawValue = value.GetDouble(); - if (rawValue < 0.0) { - throw std::runtime_error("JSON member 'crash_recovery.gpu_hang_frame_time_multiplier' must be non-negative"); - } - config.crashRecovery.gpuHangFrameTimeMultiplier = rawValue; - } - } - - const auto* validationValue = getObjectMember(document, "validation_tour", "validation_tour"); - if (validationValue) { - auto readBool = [&](const char* name, bool& target) { - if (!validationValue->HasMember(name)) { - return; - } - const auto& value = (*validationValue)[name]; - if (!value.IsBool()) { - throw std::runtime_error("JSON member 'validation_tour." + std::string(name) + "' must be a boolean"); - } - target = value.GetBool(); - }; - readBool("enabled", config.validationTour.enabled); - readBool("fail_on_mismatch", config.validationTour.failOnMismatch); - readUint32(*validationValue, "warmup_frames", "validation_tour", config.validationTour.warmupFrames); - readUint32(*validationValue, "capture_frames", "validation_tour", config.validationTour.captureFrames); - - if (validationValue->HasMember("output_dir")) { - const auto& value = (*validationValue)["output_dir"]; - if (!value.IsString()) { - throw std::runtime_error("JSON member 'validation_tour.output_dir' must be a string"); - } - config.validationTour.outputDir = value.GetString(); - } - - if (validationValue->HasMember("checkpoints")) { - const auto& checkpointsValue = (*validationValue)["checkpoints"]; - if (!checkpointsValue.IsArray()) { - throw std::runtime_error("JSON member 'validation_tour.checkpoints' must be an array"); - } - config.validationTour.checkpoints.clear(); - config.validationTour.checkpoints.reserve(checkpointsValue.Size()); - - auto readFloat3 = [&](const rapidjson::Value& value, - const std::string& path, - std::array& target) { - if (!value.IsArray() || value.Size() != 3) { - throw std::runtime_error("JSON member '" + path + "' must be an array of 3 numbers"); - } - for (rapidjson::SizeType i = 0; i < 3; ++i) { - if (!value[i].IsNumber()) { - throw std::runtime_error("JSON member '" + path + "[" + std::to_string(i) + - "]' must be a number"); - } - target[i] = static_cast(value[i].GetDouble()); - } - }; - - for (rapidjson::SizeType i = 0; i < checkpointsValue.Size(); ++i) { - const auto& entry = checkpointsValue[i]; - const std::string basePath = "validation_tour.checkpoints[" + std::to_string(i) + "]"; - if (!entry.IsObject()) { - throw std::runtime_error("JSON member '" + basePath + "' must be an object"); - } - ValidationCheckpointConfig checkpoint; - - if (!entry.HasMember("id") || !entry["id"].IsString()) { - throw std::runtime_error("JSON member '" + basePath + ".id' must be a string"); - } - checkpoint.id = entry["id"].GetString(); - - if (!entry.HasMember("camera") || !entry["camera"].IsObject()) { - throw std::runtime_error("JSON member '" + basePath + ".camera' must be an object"); - } - const auto& cameraValue = entry["camera"]; - if (!cameraValue.HasMember("position")) { - throw std::runtime_error("JSON member '" + basePath + ".camera.position' is required"); - } - readFloat3(cameraValue["position"], basePath + ".camera.position", - checkpoint.camera.position); - if (!cameraValue.HasMember("look_at")) { - throw std::runtime_error("JSON member '" + basePath + ".camera.look_at' is required"); - } - readFloat3(cameraValue["look_at"], basePath + ".camera.look_at", - checkpoint.camera.lookAt); - if (cameraValue.HasMember("up")) { - readFloat3(cameraValue["up"], basePath + ".camera.up", - checkpoint.camera.up); - } - if (cameraValue.HasMember("fov_degrees")) { - const auto& value = cameraValue["fov_degrees"]; - if (!value.IsNumber()) { - throw std::runtime_error("JSON member '" + basePath + - ".camera.fov_degrees' must be a number"); - } - checkpoint.camera.fovDegrees = static_cast(value.GetDouble()); - } - if (cameraValue.HasMember("near")) { - const auto& value = cameraValue["near"]; - if (!value.IsNumber()) { - throw std::runtime_error("JSON member '" + basePath + - ".camera.near' must be a number"); - } - checkpoint.camera.nearPlane = static_cast(value.GetDouble()); - } - if (cameraValue.HasMember("far")) { - const auto& value = cameraValue["far"]; - if (!value.IsNumber()) { - throw std::runtime_error("JSON member '" + basePath + - ".camera.far' must be a number"); - } - checkpoint.camera.farPlane = static_cast(value.GetDouble()); - } - - auto readFloatInRange = [&](const rapidjson::Value& value, - const std::string& path, - float minValue, - float maxValue) -> float { - if (!value.IsNumber()) { - throw std::runtime_error("JSON member '" + path + "' must be a number"); - } - const double rawValue = value.GetDouble(); - const double minAllowed = static_cast(minValue); - const double maxAllowed = static_cast(maxValue); - if (rawValue < minAllowed || rawValue > maxAllowed) { - throw std::runtime_error("JSON member '" + path + "' must be between " + - std::to_string(minValue) + " and " + - std::to_string(maxValue)); - } - return static_cast(rawValue); - }; - - if (entry.HasMember("expected")) { - if (!entry["expected"].IsObject()) { - throw std::runtime_error("JSON member '" + basePath + ".expected' must be an object"); - } - const auto& expectedValue = entry["expected"]; - if (!expectedValue.HasMember("image") || !expectedValue["image"].IsString()) { - throw std::runtime_error("JSON member '" + basePath + ".expected.image' must be a string"); - } - checkpoint.expected.enabled = true; - checkpoint.expected.imagePath = expectedValue["image"].GetString(); - if (expectedValue.HasMember("tolerance")) { - checkpoint.expected.tolerance = readFloatInRange( - expectedValue["tolerance"], - basePath + ".expected.tolerance", - 0.0f, - 1.0f); - } - if (expectedValue.HasMember("max_diff_pixels")) { - const auto& value = expectedValue["max_diff_pixels"]; - if (!value.IsNumber()) { - throw std::runtime_error("JSON member '" + basePath + - ".expected.max_diff_pixels' must be a number"); - } - const double rawValue = value.GetDouble(); - if (rawValue < 0.0) { - throw std::runtime_error("JSON member '" + basePath + - ".expected.max_diff_pixels' must be non-negative"); - } - checkpoint.expected.maxDiffPixels = static_cast(rawValue); - } - } - - if (entry.HasMember("checks")) { - const auto& checksValue = entry["checks"]; - if (!checksValue.IsArray()) { - throw std::runtime_error("JSON member '" + basePath + ".checks' must be an array"); - } - checkpoint.checks.clear(); - checkpoint.checks.reserve(checksValue.Size()); - - auto readFloat3Field = [&](const rapidjson::Value& value, - const std::string& path, - std::array& target) { - if (!value.IsArray() || value.Size() != 3) { - throw std::runtime_error("JSON member '" + path + "' must be an array of 3 numbers"); - } - for (rapidjson::SizeType index = 0; index < 3; ++index) { - if (!value[index].IsNumber()) { - throw std::runtime_error("JSON member '" + path + "[" + std::to_string(index) + - "]' must be a number"); - } - target[index] = static_cast(value[index].GetDouble()); - } - }; - - for (rapidjson::SizeType checkIndex = 0; checkIndex < checksValue.Size(); ++checkIndex) { - const auto& checkValue = checksValue[checkIndex]; - const std::string checkPath = basePath + ".checks[" + std::to_string(checkIndex) + "]"; - if (!checkValue.IsObject()) { - throw std::runtime_error("JSON member '" + checkPath + "' must be an object"); - } - if (!checkValue.HasMember("type") || !checkValue["type"].IsString()) { - throw std::runtime_error("JSON member '" + checkPath + ".type' must be a string"); - } - ValidationCheckConfig check{}; - check.type = checkValue["type"].GetString(); - - if (check.type == "non_black_ratio") { - if (checkValue.HasMember("threshold")) { - check.threshold = readFloatInRange( - checkValue["threshold"], - checkPath + ".threshold", - 0.0f, - 1.0f); - } - if (checkValue.HasMember("min_ratio")) { - check.minValue = readFloatInRange( - checkValue["min_ratio"], - checkPath + ".min_ratio", - 0.0f, - 1.0f); - } - if (checkValue.HasMember("max_ratio")) { - check.maxValue = readFloatInRange( - checkValue["max_ratio"], - checkPath + ".max_ratio", - 0.0f, - 1.0f); - } - if (check.minValue > check.maxValue) { - throw std::runtime_error("JSON member '" + checkPath + - "' must have min_ratio <= max_ratio"); - } - } else if (check.type == "luma_range") { - if (!checkValue.HasMember("min_luma") || !checkValue.HasMember("max_luma")) { - throw std::runtime_error("JSON member '" + checkPath + - "' must include min_luma and max_luma"); - } - check.minValue = readFloatInRange( - checkValue["min_luma"], - checkPath + ".min_luma", - 0.0f, - 1.0f); - check.maxValue = readFloatInRange( - checkValue["max_luma"], - checkPath + ".max_luma", - 0.0f, - 1.0f); - if (check.minValue > check.maxValue) { - throw std::runtime_error("JSON member '" + checkPath + - "' must have min_luma <= max_luma"); - } - } else if (check.type == "mean_color") { - if (!checkValue.HasMember("color")) { - throw std::runtime_error("JSON member '" + checkPath + ".color' is required"); - } - readFloat3Field(checkValue["color"], checkPath + ".color", check.color); - if (checkValue.HasMember("tolerance")) { - check.tolerance = readFloatInRange( - checkValue["tolerance"], - checkPath + ".tolerance", - 0.0f, - 1.0f); - } - } else if (check.type == "sample_points") { - if (!checkValue.HasMember("points") || !checkValue["points"].IsArray()) { - throw std::runtime_error("JSON member '" + checkPath + ".points' must be an array"); - } - const auto& pointsValue = checkValue["points"]; - check.points.clear(); - check.points.reserve(pointsValue.Size()); - for (rapidjson::SizeType pointIndex = 0; pointIndex < pointsValue.Size(); ++pointIndex) { - const auto& pointValue = pointsValue[pointIndex]; - const std::string pointPath = checkPath + ".points[" + std::to_string(pointIndex) + "]"; - if (!pointValue.IsObject()) { - throw std::runtime_error("JSON member '" + pointPath + "' must be an object"); - } - ValidationSamplePointConfig point{}; - if (!pointValue.HasMember("x") || !pointValue.HasMember("y")) { - throw std::runtime_error("JSON member '" + pointPath + - "' must include x and y"); - } - point.x = readFloatInRange(pointValue["x"], pointPath + ".x", 0.0f, 1.0f); - point.y = readFloatInRange(pointValue["y"], pointPath + ".y", 0.0f, 1.0f); - if (!pointValue.HasMember("color")) { - throw std::runtime_error("JSON member '" + pointPath + ".color' is required"); - } - readFloat3Field(pointValue["color"], pointPath + ".color", point.color); - if (pointValue.HasMember("tolerance")) { - point.tolerance = readFloatInRange( - pointValue["tolerance"], - pointPath + ".tolerance", - 0.0f, - 1.0f); - } - check.points.push_back(std::move(point)); - } - } else { - throw std::runtime_error("JSON member '" + checkPath + ".type' is unsupported"); - } - - checkpoint.checks.push_back(std::move(check)); - } - } - - if (!checkpoint.expected.enabled && checkpoint.checks.empty()) { - throw std::runtime_error("JSON member '" + basePath + - "' must define 'expected' or 'checks'"); - } - - config.validationTour.checkpoints.push_back(std::move(checkpoint)); - } - } - } - - return config; + return *runtimeConfig; } std::string JsonConfigService::BuildConfigJson(const RuntimeConfig& config, diff --git a/src/services/impl/runtime_config_builder.cpp b/src/services/impl/runtime_config_builder.cpp new file mode 100644 index 0000000..6060b76 --- /dev/null +++ b/src/services/impl/runtime_config_builder.cpp @@ -0,0 +1,1030 @@ +#include "runtime_config_builder.hpp" + +#include "../interfaces/config_types.hpp" + +using sdl3cpp::services::InputBindings; +using sdl3cpp::services::RuntimeConfig; +using sdl3cpp::services::SceneSource; + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { +SceneSource ParseSceneSource(const std::string& value, const std::string& jsonPath) { + if (value == "config") { + return SceneSource::Config; + } + if (value == "lua") { + return SceneSource::Lua; + } + throw std::runtime_error("JSON member '" + jsonPath + "' must be 'config' or 'lua'"); +} + +const rapidjson::Value* GetObjectMember(const rapidjson::Value& parent, + const char* name, + const char* fullName) { + if (!parent.HasMember(name)) { + return nullptr; + } + const auto& value = parent[name]; + if (!value.IsObject()) { + throw std::runtime_error("JSON member '" + std::string(fullName) + "' must be an object"); + } + return &value; +} + +uint32_t ParseDimension(const rapidjson::Value& parent, const char* name, const std::string& path) { + if (!parent.HasMember(name)) { + return 0; + } + const auto& value = parent[name]; + if (value.IsUint()) { + return value.GetUint(); + } + if (value.IsInt()) { + int maybeValue = value.GetInt(); + if (maybeValue >= 0) { + return static_cast(maybeValue); + } + } + throw std::runtime_error("JSON member '" + path + "." + std::string(name) + "' must be a non-negative integer"); +} + +uint32_t ParseDimensionValue(const rapidjson::Value& value, const char* name) { + if (value.IsUint()) { + return value.GetUint(); + } + if (value.IsInt()) { + int maybeValue = value.GetInt(); + if (maybeValue >= 0) { + return static_cast(maybeValue); + } + } + throw std::runtime_error(std::string("JSON member '") + name + "' must be a non-negative integer"); +} + +void ValidateArrayLength(const rapidjson::Value& value, + const std::string& path, + rapidjson::SizeType expected) { + if (!value.IsArray() || value.Size() != expected) { + throw std::runtime_error("JSON member '" + path + "' must be an array of " + + std::to_string(expected) + " numbers"); + } +} +} // namespace + +namespace sdl3cpp::services::impl { + +RuntimeConfigBuilder::RuntimeConfigBuilder(std::shared_ptr logger) + : logger_(std::move(logger)) {} + +RuntimeConfig RuntimeConfigBuilder::Build(const rapidjson::Document& document, + const std::filesystem::path& configPath) const { + RuntimeConfig config; + auto scriptsValue = GetObjectMember(document, "scripts", "scripts"); + auto pathsValue = GetObjectMember(document, "paths", "paths"); + auto windowValue = GetObjectMember(document, "window", "window"); + const auto* windowSizeValue = windowValue ? GetObjectMember(*windowValue, "size", "window.size") : nullptr; + auto runtimeValue = GetObjectMember(document, "runtime", "runtime"); + auto inputValue = GetObjectMember(document, "input", "input"); + const auto* inputBindingsValue = inputValue ? GetObjectMember(*inputValue, "bindings", "input.bindings") : nullptr; + auto renderingValue = GetObjectMember(document, "rendering", "rendering"); + auto guiValue = GetObjectMember(document, "gui", "gui"); + + std::optional scriptPathValue; + if (scriptsValue && scriptsValue->HasMember("entry")) { + const auto& value = (*scriptsValue)["entry"]; + if (!value.IsString()) { + throw std::runtime_error("JSON member 'scripts.entry' must be a string"); + } + scriptPathValue = value.GetString(); + } else if (document.HasMember("lua_script")) { + const auto& value = document["lua_script"]; + if (!value.IsString()) { + throw std::runtime_error("JSON member 'lua_script' must be a string"); + } + scriptPathValue = value.GetString(); + } + if (!scriptPathValue) { + throw std::runtime_error("JSON config requires a string member 'scripts.entry' or 'lua_script'"); + } + + std::optional projectRoot; + if (pathsValue && pathsValue->HasMember("project_root")) { + const auto& value = (*pathsValue)["project_root"]; + if (!value.IsString()) { + throw std::runtime_error("JSON member 'paths.project_root' must be a string"); + } + std::filesystem::path candidate(value.GetString()); + projectRoot = candidate.is_absolute() + ? std::filesystem::weakly_canonical(candidate) + : std::filesystem::weakly_canonical(configPath.parent_path() / candidate); + } else if (document.HasMember("project_root")) { + const auto& value = document["project_root"]; + if (!value.IsString()) { + throw std::runtime_error("JSON member 'project_root' must be a string"); + } + std::filesystem::path candidate(value.GetString()); + projectRoot = candidate.is_absolute() + ? std::filesystem::weakly_canonical(candidate) + : std::filesystem::weakly_canonical(configPath.parent_path() / candidate); + } + + std::filesystem::path scriptPath(*scriptPathValue); + if (!scriptPath.is_absolute()) { + scriptPath = projectRoot ? *projectRoot / scriptPath : configPath.parent_path() / scriptPath; + } + scriptPath = std::filesystem::weakly_canonical(scriptPath); + if (!std::filesystem::exists(scriptPath)) { + throw std::runtime_error("Lua script not found at " + scriptPath.string()); + } + config.scriptPath = scriptPath; + + if (runtimeValue && runtimeValue->HasMember("scene_source")) { + const auto& value = (*runtimeValue)["scene_source"]; + if (!value.IsString()) { + throw std::runtime_error("JSON member 'runtime.scene_source' must be a string"); + } + config.sceneSource = ParseSceneSource(value.GetString(), "runtime.scene_source"); + } + + if (windowSizeValue) { + if (windowSizeValue->HasMember("width")) { + config.width = ParseDimensionValue((*windowSizeValue)["width"], "window.size.width"); + } + if (windowSizeValue->HasMember("height")) { + config.height = ParseDimensionValue((*windowSizeValue)["height"], "window.size.height"); + } + } else { + if (document.HasMember("window_width")) { + config.width = ParseDimension(document, "window_width", "window_width"); + } + if (document.HasMember("window_height")) { + config.height = ParseDimension(document, "window_height", "window_height"); + } + } + + if (scriptsValue && scriptsValue->HasMember("lua_debug")) { + const auto& value = (*scriptsValue)["lua_debug"]; + if (!value.IsBool()) { + throw std::runtime_error("JSON member 'scripts.lua_debug' must be a boolean"); + } + config.luaDebug = value.GetBool(); + } else if (document.HasMember("lua_debug")) { + const auto& value = document["lua_debug"]; + if (!value.IsBool()) { + throw std::runtime_error("JSON member 'lua_debug' must be a boolean"); + } + config.luaDebug = value.GetBool(); + } + + if (windowValue && windowValue->HasMember("title")) { + const auto& value = (*windowValue)["title"]; + if (!value.IsString()) { + throw std::runtime_error("JSON member 'window.title' must be a string"); + } + config.windowTitle = value.GetString(); + } else if (document.HasMember("window_title")) { + const auto& value = document["window_title"]; + if (!value.IsString()) { + throw std::runtime_error("JSON member 'window_title' must be a string"); + } + config.windowTitle = value.GetString(); + } + + const rapidjson::Value* mouseGrabValue = nullptr; + std::string mouseGrabPath = "mouse_grab"; + if (windowValue && windowValue->HasMember("mouse_grab")) { + const auto& value = (*windowValue)["mouse_grab"]; + if (!value.IsObject()) { + throw std::runtime_error("JSON member 'window.mouse_grab' must be an object"); + } + mouseGrabValue = &value; + mouseGrabPath = "window.mouse_grab"; + } else if (document.HasMember("mouse_grab")) { + const auto& value = document["mouse_grab"]; + if (!value.IsObject()) { + throw std::runtime_error("JSON member 'mouse_grab' must be an object"); + } + mouseGrabValue = &value; + } + + if (mouseGrabValue) { + auto readBool = [&](const char* name, bool& target) { + if (!mouseGrabValue->HasMember(name)) { + return; + } + const auto& value = (*mouseGrabValue)[name]; + if (!value.IsBool()) { + throw std::runtime_error("JSON member '" + mouseGrabPath + "." + std::string(name) + "' must be a boolean"); + } + target = value.GetBool(); + }; + auto readString = [&](const char* name, std::string& target) { + if (!mouseGrabValue->HasMember(name)) { + return; + } + const auto& value = (*mouseGrabValue)[name]; + if (!value.IsString()) { + throw std::runtime_error("JSON member '" + mouseGrabPath + "." + std::string(name) + "' must be a string"); + } + target = value.GetString(); + }; + readBool("enabled", config.mouseGrab.enabled); + readBool("grab_on_click", config.mouseGrab.grabOnClick); + readBool("release_on_escape", config.mouseGrab.releaseOnEscape); + readBool("start_grabbed", config.mouseGrab.startGrabbed); + readBool("hide_cursor", config.mouseGrab.hideCursor); + readBool("relative_mode", config.mouseGrab.relativeMode); + readString("grab_mouse_button", config.mouseGrab.grabMouseButton); + readString("release_key", config.mouseGrab.releaseKey); + } + + const rapidjson::Value* bindingsValue = nullptr; + std::string bindingsPath = "input_bindings"; + if (inputBindingsValue) { + bindingsValue = inputBindingsValue; + bindingsPath = "input.bindings"; + } else if (document.HasMember("input_bindings")) { + const auto& value = document["input_bindings"]; + if (!value.IsObject()) { + throw std::runtime_error("JSON member 'input_bindings' must be an object"); + } + bindingsValue = &value; + } + + if (bindingsValue) { + struct BindingSpec { + const char* name; + std::string InputBindings::* member; + }; + const std::array bindingSpecs = {{ + {"move_forward", &InputBindings::moveForwardKey}, + {"move_back", &InputBindings::moveBackKey}, + {"move_left", &InputBindings::moveLeftKey}, + {"move_right", &InputBindings::moveRightKey}, + {"fly_up", &InputBindings::flyUpKey}, + {"fly_down", &InputBindings::flyDownKey}, + {"jump", &InputBindings::jumpKey}, + {"noclip_toggle", &InputBindings::noclipToggleKey}, + {"music_toggle", &InputBindings::musicToggleKey}, + {"music_toggle_gamepad", &InputBindings::musicToggleGamepadButton}, + {"gamepad_move_x_axis", &InputBindings::gamepadMoveXAxis}, + {"gamepad_move_y_axis", &InputBindings::gamepadMoveYAxis}, + {"gamepad_look_x_axis", &InputBindings::gamepadLookXAxis}, + {"gamepad_look_y_axis", &InputBindings::gamepadLookYAxis}, + {"gamepad_dpad_up", &InputBindings::gamepadDpadUpButton}, + {"gamepad_dpad_down", &InputBindings::gamepadDpadDownButton}, + {"gamepad_dpad_left", &InputBindings::gamepadDpadLeftButton}, + {"gamepad_dpad_right", &InputBindings::gamepadDpadRightButton}, + }}; + + auto readBinding = [&](const BindingSpec& spec) { + if (!bindingsValue->HasMember(spec.name)) { + return; + } + const auto& value = (*bindingsValue)[spec.name]; + if (!value.IsString()) { + throw std::runtime_error("JSON member '" + bindingsPath + "." + std::string(spec.name) + "' must be a string"); + } + config.inputBindings.*(spec.member) = value.GetString(); + }; + + for (const auto& spec : bindingSpecs) { + readBinding(spec); + } + + auto readMapping = [&](const char* name, + std::unordered_map& target) { + if (!bindingsValue->HasMember(name)) { + return; + } + const auto& mappingValue = (*bindingsValue)[name]; + if (!mappingValue.IsObject()) { + throw std::runtime_error("JSON member '" + bindingsPath + "." + std::string(name) + "' must be an object"); + } + for (auto it = mappingValue.MemberBegin(); it != mappingValue.MemberEnd(); ++it) { + if (!it->name.IsString() || !it->value.IsString()) { + throw std::runtime_error("JSON member '" + bindingsPath + "." + std::string(name) + + "' must contain string pairs"); + } + std::string key = it->name.GetString(); + std::string value = it->value.GetString(); + if (value.empty()) { + target.erase(key); + } else { + target[key] = value; + } + } + }; + + readMapping("gamepad_button_actions", config.inputBindings.gamepadButtonActions); + readMapping("gamepad_axis_actions", config.inputBindings.gamepadAxisActions); + + if (bindingsValue->HasMember("gamepad_axis_action_threshold")) { + const auto& value = (*bindingsValue)["gamepad_axis_action_threshold"]; + if (!value.IsNumber()) { + throw std::runtime_error("JSON member '" + bindingsPath + ".gamepad_axis_action_threshold' must be a number"); + } + config.inputBindings.gamepadAxisActionThreshold = static_cast(value.GetDouble()); + } + } + + const rapidjson::Value* atmosphericsValue = nullptr; + std::string atmosphericsPath = "atmospherics"; + if (renderingValue) { + atmosphericsValue = GetObjectMember(*renderingValue, "atmospherics", "rendering.atmospherics"); + if (atmosphericsValue) { + atmosphericsPath = "rendering.atmospherics"; + } + } + if (!atmosphericsValue && document.HasMember("atmospherics")) { + const auto& value = document["atmospherics"]; + if (!value.IsObject()) { + throw std::runtime_error("JSON member 'atmospherics' must be an object"); + } + atmosphericsValue = &value; + } + if (atmosphericsValue) { + auto readFloat = [&](const char* name, float& target) { + if (!atmosphericsValue->HasMember(name)) { + return; + } + const auto& value = (*atmosphericsValue)[name]; + if (!value.IsNumber()) { + throw std::runtime_error("JSON member '" + atmosphericsPath + "." + std::string(name) + + "' must be a number"); + } + target = static_cast(value.GetDouble()); + }; + auto readBool = [&](const char* name, bool& target) { + if (!atmosphericsValue->HasMember(name)) { + return; + } + const auto& value = (*atmosphericsValue)[name]; + if (!value.IsBool()) { + throw std::runtime_error("JSON member '" + atmosphericsPath + "." + std::string(name) + + "' must be a boolean"); + } + target = value.GetBool(); + }; + auto readFloatArray3 = [&](const char* name, std::array& target) { + if (!atmosphericsValue->HasMember(name)) { + return; + } + const auto& value = (*atmosphericsValue)[name]; + ValidateArrayLength(value, atmosphericsPath + "." + name, 3); + for (rapidjson::SizeType i = 0; i < 3; ++i) { + if (!value[i].IsNumber()) { + throw std::runtime_error("JSON member '" + atmosphericsPath + "." + std::string(name) + + "[" + std::to_string(i) + "]' must be a number"); + } + target[i] = static_cast(value[i].GetDouble()); + } + }; + readFloat("ambient_strength", config.atmospherics.ambientStrength); + readFloat("fog_density", config.atmospherics.fogDensity); + readFloatArray3("fog_color", config.atmospherics.fogColor); + readFloatArray3("sky_color", config.atmospherics.skyColor); + readFloat("gamma", config.atmospherics.gamma); + readFloat("exposure", config.atmospherics.exposure); + readBool("enable_tone_mapping", config.atmospherics.enableToneMapping); + readBool("enable_shadows", config.atmospherics.enableShadows); + readBool("enable_ssgi", config.atmospherics.enableSSGI); + readBool("enable_volumetric_lighting", config.atmospherics.enableVolumetricLighting); + readFloat("pbr_roughness", config.atmospherics.pbrRoughness); + readFloat("pbr_metallic", config.atmospherics.pbrMetallic); + } + + const rapidjson::Value* bgfxValue = nullptr; + std::string bgfxPath = "bgfx"; + if (renderingValue) { + bgfxValue = GetObjectMember(*renderingValue, "bgfx", "rendering.bgfx"); + if (bgfxValue) { + bgfxPath = "rendering.bgfx"; + } + } + if (!bgfxValue && document.HasMember("bgfx")) { + const auto& value = document["bgfx"]; + if (!value.IsObject()) { + throw std::runtime_error("JSON member 'bgfx' must be an object"); + } + bgfxValue = &value; + } + if (bgfxValue && bgfxValue->HasMember("renderer")) { + const auto& value = (*bgfxValue)["renderer"]; + if (!value.IsString()) { + throw std::runtime_error("JSON member '" + bgfxPath + ".renderer' must be a string"); + } + config.bgfx.renderer = value.GetString(); + } + + const rapidjson::Value* materialValue = nullptr; + std::string materialPath = "materialx"; + if (renderingValue) { + materialValue = GetObjectMember(*renderingValue, "materialx", "rendering.materialx"); + if (materialValue) { + materialPath = "rendering.materialx"; + } + } + if (!materialValue && document.HasMember("materialx")) { + const auto& value = document["materialx"]; + if (!value.IsObject()) { + throw std::runtime_error("JSON member 'materialx' must be an object"); + } + materialValue = &value; + } + + bool materialShaderKeyProvided = false; + if (materialValue) { + if (materialValue->HasMember("enabled")) { + const auto& value = (*materialValue)["enabled"]; + if (!value.IsBool()) { + throw std::runtime_error("JSON member '" + materialPath + ".enabled' must be a boolean"); + } + config.materialX.enabled = value.GetBool(); + } + if (materialValue->HasMember("document")) { + const auto& value = (*materialValue)["document"]; + if (!value.IsString()) { + throw std::runtime_error("JSON member '" + materialPath + ".document' must be a string"); + } + config.materialX.documentPath = value.GetString(); + } + if (materialValue->HasMember("shader_key")) { + const auto& value = (*materialValue)["shader_key"]; + if (!value.IsString()) { + throw std::runtime_error("JSON member '" + materialPath + ".shader_key' must be a string"); + } + config.materialX.shaderKey = value.GetString(); + materialShaderKeyProvided = true; + } + if (materialValue->HasMember("material")) { + const auto& value = (*materialValue)["material"]; + if (!value.IsString()) { + throw std::runtime_error("JSON member '" + materialPath + ".material' must be a string"); + } + config.materialX.materialName = value.GetString(); + } + if (materialValue->HasMember("library_path")) { + const auto& value = (*materialValue)["library_path"]; + if (!value.IsString()) { + throw std::runtime_error("JSON member '" + materialPath + ".library_path' must be a string"); + } + config.materialX.libraryPath = value.GetString(); + } + if (materialValue->HasMember("library_folders")) { + const auto& value = (*materialValue)["library_folders"]; + if (!value.IsArray()) { + throw std::runtime_error("JSON member '" + materialPath + ".library_folders' must be an array"); + } + config.materialX.libraryFolders.clear(); + for (rapidjson::SizeType i = 0; i < value.Size(); ++i) { + if (!value[i].IsString()) { + throw std::runtime_error("JSON member '" + materialPath + ".library_folders[" + + std::to_string(i) + "]' must be a string"); + } + config.materialX.libraryFolders.emplace_back(value[i].GetString()); + } + } + if (materialValue->HasMember("use_constant_color")) { + const auto& value = (*materialValue)["use_constant_color"]; + if (!value.IsBool()) { + throw std::runtime_error("JSON member '" + materialPath + ".use_constant_color' must be a boolean"); + } + config.materialX.useConstantColor = value.GetBool(); + } + if (materialValue->HasMember("constant_color")) { + const auto& value = (*materialValue)["constant_color"]; + ValidateArrayLength(value, materialPath + ".constant_color", 3); + for (rapidjson::SizeType i = 0; i < 3; ++i) { + if (!value[i].IsNumber()) { + throw std::runtime_error("JSON member '" + materialPath + ".constant_color[" + + std::to_string(i) + "]' must be a number"); + } + config.materialX.constantColor[i] = static_cast(value[i].GetDouble()); + } + } + } + + const rapidjson::Value* materialsValue = nullptr; + std::string materialsPath = "materialx_materials"; + if (materialValue && materialValue->HasMember("materials")) { + const auto& value = (*materialValue)["materials"]; + if (!value.IsArray()) { + throw std::runtime_error("JSON member '" + materialPath + ".materials' must be an array"); + } + materialsValue = &value; + materialsPath = materialPath + ".materials"; + } + if (!materialsValue && document.HasMember("materialx_materials")) { + const auto& value = document["materialx_materials"]; + if (!value.IsArray()) { + throw std::runtime_error("JSON member 'materialx_materials' must be an array"); + } + materialsValue = &value; + } + if (materialsValue) { + config.materialXMaterials.clear(); + for (rapidjson::SizeType i = 0; i < materialsValue->Size(); ++i) { + const auto& entry = (*materialsValue)[i]; + if (!entry.IsObject()) { + throw std::runtime_error("JSON member '" + materialsPath + "[" + std::to_string(i) + + "]' must be an object"); + } + MaterialXMaterialConfig materialConfig; + if (entry.HasMember("enabled")) { + const auto& value = entry["enabled"]; + if (!value.IsBool()) { + throw std::runtime_error("JSON member '" + materialsPath + "[" + std::to_string(i) + + "].enabled' must be a boolean"); + } + materialConfig.enabled = value.GetBool(); + } + if (entry.HasMember("document")) { + const auto& value = entry["document"]; + if (!value.IsString()) { + throw std::runtime_error("JSON member '" + materialsPath + "[" + std::to_string(i) + + "].document' must be a string"); + } + materialConfig.documentPath = value.GetString(); + } + if (entry.HasMember("shader_key")) { + const auto& value = entry["shader_key"]; + if (!value.IsString()) { + throw std::runtime_error("JSON member '" + materialsPath + "[" + std::to_string(i) + + "].shader_key' must be a string"); + } + materialConfig.shaderKey = value.GetString(); + } + if (entry.HasMember("material")) { + const auto& value = entry["material"]; + if (!value.IsString()) { + throw std::runtime_error("JSON member '" + materialsPath + "[" + std::to_string(i) + + "].material' must be a string"); + } + materialConfig.materialName = value.GetString(); + } + if (entry.HasMember("use_constant_color")) { + const auto& value = entry["use_constant_color"]; + if (!value.IsBool()) { + throw std::runtime_error("JSON member '" + materialsPath + "[" + std::to_string(i) + + "].use_constant_color' must be a boolean"); + } + materialConfig.useConstantColor = value.GetBool(); + } + if (entry.HasMember("constant_color")) { + const auto& value = entry["constant_color"]; + ValidateArrayLength(value, materialsPath + "[" + std::to_string(i) + "].constant_color", 3); + for (rapidjson::SizeType channel = 0; channel < 3; ++channel) { + if (!value[channel].IsNumber()) { + throw std::runtime_error("JSON member '" + materialsPath + "[" + std::to_string(i) + + "].constant_color[" + std::to_string(channel) + + "]' must be a number"); + } + materialConfig.constantColor[channel] = static_cast(value[channel].GetDouble()); + } + } + + if (materialConfig.shaderKey.empty()) { + throw std::runtime_error("JSON member '" + materialsPath + "[" + std::to_string(i) + + "].shader_key' must be provided"); + } + if (materialConfig.documentPath.empty() && !materialConfig.useConstantColor) { + throw std::runtime_error("JSON member '" + materialsPath + "[" + std::to_string(i) + + "].document' is required when use_constant_color is false"); + } + + config.materialXMaterials.push_back(std::move(materialConfig)); + } + } + + if (!materialShaderKeyProvided && !config.materialXMaterials.empty()) { + config.materialX.shaderKey = config.materialXMaterials.front().shaderKey; + if (logger_) { + logger_->Trace("RuntimeConfigBuilder", "Build", + "materialx.shader_key not set; defaulting to first materialx_materials key=" + + config.materialX.shaderKey); + } + } + + const rapidjson::Value* guiFontValue = nullptr; + std::string guiFontPath = "gui_font"; + if (guiValue && guiValue->HasMember("font")) { + const auto& value = (*guiValue)["font"]; + if (!value.IsObject()) { + throw std::runtime_error("JSON member 'gui.font' must be an object"); + } + guiFontValue = &value; + guiFontPath = "gui.font"; + } else if (document.HasMember("gui_font")) { + const auto& value = document["gui_font"]; + if (!value.IsObject()) { + throw std::runtime_error("JSON member 'gui_font' must be an object"); + } + guiFontValue = &value; + } + if (guiFontValue) { + if (guiFontValue->HasMember("use_freetype")) { + const auto& value = (*guiFontValue)["use_freetype"]; + if (!value.IsBool()) { + throw std::runtime_error("JSON member '" + guiFontPath + ".use_freetype' must be a boolean"); + } + config.guiFont.useFreeType = value.GetBool(); + } + if (guiFontValue->HasMember("font_path")) { + const auto& value = (*guiFontValue)["font_path"]; + if (!value.IsString()) { + throw std::runtime_error("JSON member '" + guiFontPath + ".font_path' must be a string"); + } + config.guiFont.fontPath = value.GetString(); + } + if (guiFontValue->HasMember("font_size")) { + const auto& value = (*guiFontValue)["font_size"]; + if (!value.IsNumber()) { + throw std::runtime_error("JSON member '" + guiFontPath + ".font_size' must be a number"); + } + config.guiFont.fontSize = static_cast(value.GetDouble()); + } + } + + if (guiValue && guiValue->HasMember("opacity")) { + const auto& value = (*guiValue)["opacity"]; + if (!value.IsNumber()) { + throw std::runtime_error("JSON member 'gui.opacity' must be a number"); + } + config.guiOpacity = static_cast(value.GetDouble()); + } else if (document.HasMember("gui_opacity")) { + const auto& value = document["gui_opacity"]; + if (!value.IsNumber()) { + throw std::runtime_error("JSON member 'gui_opacity' must be a number"); + } + config.guiOpacity = static_cast(value.GetDouble()); + } + + auto readSizeT = [](const rapidjson::Value& parent, + const char* name, + const std::string& path, + size_t& target) { + if (!parent.HasMember(name)) { + return; + } + const auto& value = parent[name]; + if (!value.IsNumber()) { + throw std::runtime_error("JSON member '" + path + "." + std::string(name) + "' must be a number"); + } + const double rawValue = value.GetDouble(); + if (rawValue < 0.0) { + throw std::runtime_error("JSON member '" + path + "." + std::string(name) + "' must be non-negative"); + } + target = static_cast(rawValue); + }; + + auto readUint32 = [](const rapidjson::Value& parent, + const char* name, + const std::string& path, + uint32_t& target) { + if (!parent.HasMember(name)) { + return; + } + const auto& value = parent[name]; + if (!value.IsNumber()) { + throw std::runtime_error("JSON member '" + path + "." + std::string(name) + "' must be a number"); + } + const double rawValue = value.GetDouble(); + if (rawValue < 0.0) { + throw std::runtime_error("JSON member '" + path + "." + std::string(name) + "' must be non-negative"); + } + target = static_cast(rawValue); + }; + + const auto* budgetsValue = GetObjectMember(document, "budgets", "budgets"); + if (budgetsValue) { + readSizeT(*budgetsValue, "vram_mb", "budgets", config.budgets.vramMB); + readUint32(*budgetsValue, "max_texture_dim", "budgets", config.budgets.maxTextureDim); + readSizeT(*budgetsValue, "gui_text_cache_entries", "budgets", config.budgets.guiTextCacheEntries); + readSizeT(*budgetsValue, "gui_svg_cache_entries", "budgets", config.budgets.guiSvgCacheEntries); + } + + const auto* crashRecoveryValue = GetObjectMember(document, "crash_recovery", "crash_recovery"); + if (crashRecoveryValue) { + readUint32(*crashRecoveryValue, "heartbeat_timeout_ms", + "crash_recovery", config.crashRecovery.heartbeatTimeoutMs); + readUint32(*crashRecoveryValue, "heartbeat_poll_interval_ms", + "crash_recovery", config.crashRecovery.heartbeatPollIntervalMs); + readSizeT(*crashRecoveryValue, "memory_limit_mb", + "crash_recovery", config.crashRecovery.memoryLimitMB); + readSizeT(*crashRecoveryValue, "max_consecutive_gpu_timeouts", + "crash_recovery", config.crashRecovery.maxConsecutiveGpuTimeouts); + readSizeT(*crashRecoveryValue, "max_lua_failures", + "crash_recovery", config.crashRecovery.maxLuaFailures); + readSizeT(*crashRecoveryValue, "max_file_format_errors", + "crash_recovery", config.crashRecovery.maxFileFormatErrors); + readSizeT(*crashRecoveryValue, "max_memory_warnings", + "crash_recovery", config.crashRecovery.maxMemoryWarnings); + if (crashRecoveryValue->HasMember("gpu_hang_frame_time_multiplier")) { + const auto& value = (*crashRecoveryValue)["gpu_hang_frame_time_multiplier"]; + if (!value.IsNumber()) { + throw std::runtime_error("JSON member 'crash_recovery.gpu_hang_frame_time_multiplier' must be a number"); + } + const double rawValue = value.GetDouble(); + if (rawValue < 0.0) { + throw std::runtime_error("JSON member 'crash_recovery.gpu_hang_frame_time_multiplier' must be non-negative"); + } + config.crashRecovery.gpuHangFrameTimeMultiplier = rawValue; + } + } + + const auto* validationValue = GetObjectMember(document, "validation_tour", "validation_tour"); + if (validationValue) { + auto readBool = [&](const char* name, bool& target) { + if (!validationValue->HasMember(name)) { + return; + } + const auto& value = (*validationValue)[name]; + if (!value.IsBool()) { + throw std::runtime_error("JSON member 'validation_tour." + std::string(name) + "' must be a boolean"); + } + target = value.GetBool(); + }; + readBool("enabled", config.validationTour.enabled); + readBool("fail_on_mismatch", config.validationTour.failOnMismatch); + readUint32(*validationValue, "warmup_frames", "validation_tour", config.validationTour.warmupFrames); + readUint32(*validationValue, "capture_frames", "validation_tour", config.validationTour.captureFrames); + + if (validationValue->HasMember("output_dir")) { + const auto& value = (*validationValue)["output_dir"]; + if (!value.IsString()) { + throw std::runtime_error("JSON member 'validation_tour.output_dir' must be a string"); + } + config.validationTour.outputDir = value.GetString(); + } + + if (validationValue->HasMember("checkpoints")) { + const auto& checkpointsValue = (*validationValue)["checkpoints"]; + if (!checkpointsValue.IsArray()) { + throw std::runtime_error("JSON member 'validation_tour.checkpoints' must be an array"); + } + config.validationTour.checkpoints.clear(); + config.validationTour.checkpoints.reserve(checkpointsValue.Size()); + + auto readFloat3 = [&](const rapidjson::Value& value, + const std::string& path, + std::array& target) { + ValidateArrayLength(value, path, 3); + for (rapidjson::SizeType i = 0; i < 3; ++i) { + if (!value[i].IsNumber()) { + throw std::runtime_error("JSON member '" + path + "[" + std::to_string(i) + + "]' must be a number"); + } + target[i] = static_cast(value[i].GetDouble()); + } + }; + + for (rapidjson::SizeType i = 0; i < checkpointsValue.Size(); ++i) { + const auto& entry = checkpointsValue[i]; + const std::string basePath = "validation_tour.checkpoints[" + std::to_string(i) + "]"; + if (!entry.IsObject()) { + throw std::runtime_error("JSON member '" + basePath + "' must be an object"); + } + ValidationCheckpointConfig checkpoint; + + if (!entry.HasMember("id") || !entry["id"].IsString()) { + throw std::runtime_error("JSON member '" + basePath + ".id' must be a string"); + } + checkpoint.id = entry["id"].GetString(); + + if (!entry.HasMember("camera") || !entry["camera"].IsObject()) { + throw std::runtime_error("JSON member '" + basePath + ".camera' must be an object"); + } + const auto& cameraValue = entry["camera"]; + if (!cameraValue.HasMember("position")) { + throw std::runtime_error("JSON member '" + basePath + ".camera.position' is required"); + } + readFloat3(cameraValue["position"], basePath + ".camera.position", checkpoint.camera.position); + if (!cameraValue.HasMember("look_at")) { + throw std::runtime_error("JSON member '" + basePath + ".camera.look_at' is required"); + } + readFloat3(cameraValue["look_at"], basePath + ".camera.look_at", checkpoint.camera.lookAt); + if (cameraValue.HasMember("up")) { + readFloat3(cameraValue["up"], basePath + ".camera.up", checkpoint.camera.up); + } + if (cameraValue.HasMember("fov_degrees")) { + const auto& value = cameraValue["fov_degrees"]; + if (!value.IsNumber()) { + throw std::runtime_error("JSON member '" + basePath + ".camera.fov_degrees' must be a number"); + } + checkpoint.camera.fovDegrees = static_cast(value.GetDouble()); + } + if (cameraValue.HasMember("near")) { + const auto& value = cameraValue["near"]; + if (!value.IsNumber()) { + throw std::runtime_error("JSON member '" + basePath + ".camera.near' must be a number"); + } + checkpoint.camera.nearPlane = static_cast(value.GetDouble()); + } + if (cameraValue.HasMember("far")) { + const auto& value = cameraValue["far"]; + if (!value.IsNumber()) { + throw std::runtime_error("JSON member '" + basePath + ".camera.far' must be a number"); + } + checkpoint.camera.farPlane = static_cast(value.GetDouble()); + } + + auto readFloatInRange = [&](const rapidjson::Value& value, + const std::string& path, + double minValue, + double maxValue) -> float { + if (!value.IsNumber()) { + throw std::runtime_error("JSON member '" + path + "' must be a number"); + } + const double rawValue = value.GetDouble(); + if (rawValue < minValue || rawValue > maxValue) { + throw std::runtime_error("JSON member '" + path + "' must be between " + + std::to_string(minValue) + " and " + + std::to_string(maxValue)); + } + return static_cast(rawValue); + }; + + if (entry.HasMember("expected")) { + if (!entry["expected"].IsObject()) { + throw std::runtime_error("JSON member '" + basePath + ".expected' must be an object"); + } + const auto& expectedValue = entry["expected"]; + if (!expectedValue.HasMember("image") || !expectedValue["image"].IsString()) { + throw std::runtime_error("JSON member '" + basePath + ".expected.image' must be a string"); + } + checkpoint.expected.enabled = true; + checkpoint.expected.imagePath = expectedValue["image"].GetString(); + if (expectedValue.HasMember("tolerance")) { + checkpoint.expected.tolerance = readFloatInRange( + expectedValue["tolerance"], + basePath + ".expected.tolerance", + 0.0f, + 1.0f); + } + if (expectedValue.HasMember("max_diff_pixels")) { + const auto& value = expectedValue["max_diff_pixels"]; + if (!value.IsNumber()) { + throw std::runtime_error("JSON member '" + basePath + + ".expected.max_diff_pixels' must be a number"); + } + const double rawValue = value.GetDouble(); + if (rawValue < 0.0) { + throw std::runtime_error("JSON member '" + basePath + + ".expected.max_diff_pixels' must be non-negative"); + } + checkpoint.expected.maxDiffPixels = static_cast(rawValue); + } + } + + if (entry.HasMember("checks")) { + const auto& checksValue = entry["checks"]; + if (!checksValue.IsArray()) { + throw std::runtime_error("JSON member '" + basePath + ".checks' must be an array"); + } + checkpoint.checks.clear(); + checkpoint.checks.reserve(checksValue.Size()); + + auto readFloat3Field = [&](const rapidjson::Value& value, + const std::string& path, + std::array& target) { + ValidateArrayLength(value, path, 3); + for (rapidjson::SizeType index = 0; index < 3; ++index) { + if (!value[index].IsNumber()) { + throw std::runtime_error("JSON member '" + path + "[" + std::to_string(index) + + "]' must be a number"); + } + target[index] = static_cast(value[index].GetDouble()); + } + }; + + for (rapidjson::SizeType checkIndex = 0; checkIndex < checksValue.Size(); ++checkIndex) { + const auto& checkValue = checksValue[checkIndex]; + const std::string checkPath = basePath + ".checks[" + std::to_string(checkIndex) + "]"; + if (!checkValue.IsObject()) { + throw std::runtime_error("JSON member '" + checkPath + "' must be an object"); + } + if (!checkValue.HasMember("type") || !checkValue["type"].IsString()) { + throw std::runtime_error("JSON member '" + checkPath + ".type' must be a string"); + } + ValidationCheckConfig check{}; + check.type = checkValue["type"].GetString(); + + if (check.type == "non_black_ratio") { + if (checkValue.HasMember("threshold")) { + check.threshold = readFloatInRange( + checkValue["threshold"], + checkPath + ".threshold", + 0.0f, + 1.0f); + } + if (checkValue.HasMember("min_ratio")) { + check.minValue = readFloatInRange( + checkValue["min_ratio"], + checkPath + ".min_ratio", + 0.0f, + 1.0f); + } + if (checkValue.HasMember("max_ratio")) { + check.maxValue = readFloatInRange( + checkValue["max_ratio"], + checkPath + ".max_ratio", + 0.0f, + 1.0f); + } + if (check.minValue > check.maxValue) { + throw std::runtime_error("JSON member '" + checkPath + + "' must have min_ratio <= max_ratio"); + } + } else if (check.type == "luma_range") { + if (!checkValue.HasMember("min_luma") || !checkValue.HasMember("max_luma")) { + throw std::runtime_error("JSON member '" + checkPath + + "' must include min_luma and max_luma"); + } + check.minValue = readFloatInRange( + checkValue["min_luma"], + checkPath + ".min_luma", + 0.0f, + 1.0f); + check.maxValue = readFloatInRange( + checkValue["max_luma"], + checkPath + ".max_luma", + 0.0f, + 1.0f); + if (check.minValue > check.maxValue) { + throw std::runtime_error("JSON member '" + checkPath + + "' must have min_luma <= max_luma"); + } + } else if (check.type == "mean_color") { + if (!checkValue.HasMember("color")) { + throw std::runtime_error("JSON member '" + checkPath + ".color' is required"); + } + readFloat3Field(checkValue["color"], checkPath + ".color", check.color); + if (checkValue.HasMember("tolerance")) { + check.tolerance = readFloatInRange( + checkValue["tolerance"], + checkPath + ".tolerance", + 0.0f, + 1.0f); + } + } else if (check.type == "sample_points") { + if (!checkValue.HasMember("points") || !checkValue["points"].IsArray()) { + throw std::runtime_error("JSON member '" + checkPath + ".points' must be an array"); + } + const auto& pointsValue = checkValue["points"]; + check.points.clear(); + check.points.reserve(pointsValue.Size()); + for (rapidjson::SizeType pointIndex = 0; pointIndex < pointsValue.Size(); ++pointIndex) { + const auto& pointValue = pointsValue[pointIndex]; + const std::string pointPath = checkPath + ".points[" + std::to_string(pointIndex) + "]"; + if (!pointValue.IsObject()) { + throw std::runtime_error("JSON member '" + pointPath + "' must be an object"); + } + ValidationSamplePointConfig point{}; + if (!pointValue.HasMember("x") || !pointValue.HasMember("y")) { + throw std::runtime_error("JSON member '" + pointPath + + "' must include x and y"); + } + point.x = readFloatInRange(pointValue["x"], pointPath + ".x", 0.0f, 1.0f); + point.y = readFloatInRange(pointValue["y"], pointPath + ".y", 0.0f, 1.0f); + if (!pointValue.HasMember("color")) { + throw std::runtime_error("JSON member '" + pointPath + ".color' is required"); + } + readFloat3Field(pointValue["color"], pointPath + ".color", point.color); + if (pointValue.HasMember("tolerance")) { + point.tolerance = readFloatInRange( + pointValue["tolerance"], + pointPath + ".tolerance", + 0.0f, + 1.0f); + } + check.points.push_back(std::move(point)); + } + } else { + throw std::runtime_error("JSON member '" + checkPath + ".type' is unsupported"); + } + checkpoint.checks.push_back(std::move(check)); + } + } + + if (!checkpoint.expected.enabled && checkpoint.checks.empty()) { + throw std::runtime_error("JSON member '" + basePath + + "' must define 'expected' or 'checks'"); + } + config.validationTour.checkpoints.push_back(std::move(checkpoint)); + } + } + } + + return config; +} + +} // namespace sdl3cpp::services::impl diff --git a/src/services/impl/runtime_config_builder.hpp b/src/services/impl/runtime_config_builder.hpp new file mode 100644 index 0000000..2024fad --- /dev/null +++ b/src/services/impl/runtime_config_builder.hpp @@ -0,0 +1,24 @@ +#pragma once + +#include "../interfaces/config_types.hpp" +#include "../interfaces/i_logger.hpp" + +#include +#include + +#include + +namespace sdl3cpp::services::impl { + +class RuntimeConfigBuilder final { +public: + explicit RuntimeConfigBuilder(std::shared_ptr logger); + + services::RuntimeConfig Build(const rapidjson::Document& document, + const std::filesystem::path& configPath) const; + +private: + std::shared_ptr logger_; +}; + +} // namespace sdl3cpp::services::impl diff --git a/src/services/impl/workflow_config_pipeline.cpp b/src/services/impl/workflow_config_pipeline.cpp index 8a69280..0f7e13c 100644 --- a/src/services/impl/workflow_config_pipeline.cpp +++ b/src/services/impl/workflow_config_pipeline.cpp @@ -7,11 +7,11 @@ #include "workflow_template_resolver.hpp" #include "../interfaces/i_logger.hpp" #include "../interfaces/i_probe_service.hpp" +#include "../interfaces/workflow_context.hpp" #include #include #include - namespace sdl3cpp::services::impl { WorkflowConfigPipeline::WorkflowConfigPipeline(std::shared_ptr logger, @@ -19,9 +19,8 @@ WorkflowConfigPipeline::WorkflowConfigPipeline(std::shared_ptr logger, : logger_(std::move(logger)), probeService_(std::move(probeService)) {} -std::shared_ptr WorkflowConfigPipeline::Execute( - const std::filesystem::path& configPath, - std::optional* versionOut) const { +WorkflowResult WorkflowConfigPipeline::Execute(const std::filesystem::path& configPath, + std::optional* versionOut) const { if (logger_) { logger_->Trace("WorkflowConfigPipeline", "Execute", "configPath=" + configPath.string(), @@ -51,10 +50,6 @@ std::shared_ptr WorkflowConfigPipeline::Execute( if (!documentHandle || !(*documentHandle)) { throw std::runtime_error("WorkflowConfigPipeline: boot workflow did not provide config.document"); } - if (versionOut) { - const auto* versionHandle = context.TryGet>("config.version"); - *versionOut = versionHandle ? *versionHandle : std::nullopt; - } if (logger_) { logger_->Trace("WorkflowConfigPipeline", "Execute", @@ -62,7 +57,14 @@ std::shared_ptr WorkflowConfigPipeline::Execute( "Boot workflow complete"); } - return *documentHandle; + WorkflowResult result; + result.document = *documentHandle; + if (versionOut) { + const auto* versionHandle = context.TryGet>("config.version"); + *versionOut = versionHandle ? *versionHandle : std::nullopt; + } + result.context = std::move(context); + return result; } } // namespace sdl3cpp::services::impl diff --git a/src/services/impl/workflow_config_pipeline.hpp b/src/services/impl/workflow_config_pipeline.hpp index a6b3728..8cfce0d 100644 --- a/src/services/impl/workflow_config_pipeline.hpp +++ b/src/services/impl/workflow_config_pipeline.hpp @@ -4,6 +4,8 @@ #include #include +#include "../interfaces/workflow_context.hpp" + #include namespace sdl3cpp::services { @@ -13,13 +15,18 @@ class IProbeService; namespace sdl3cpp::services::impl { +struct WorkflowResult { + WorkflowContext context; + std::shared_ptr document; +}; + class WorkflowConfigPipeline { public: WorkflowConfigPipeline(std::shared_ptr logger, std::shared_ptr probeService); - std::shared_ptr Execute(const std::filesystem::path& configPath, - std::optional* versionOut) const; + WorkflowResult Execute(const std::filesystem::path& configPath, + std::optional* versionOut) const; private: std::shared_ptr logger_; diff --git a/src/services/impl/workflow_default_step_registrar.cpp b/src/services/impl/workflow_default_step_registrar.cpp index 8a629b1..1606bbb 100644 --- a/src/services/impl/workflow_default_step_registrar.cpp +++ b/src/services/impl/workflow_default_step_registrar.cpp @@ -3,6 +3,7 @@ #include "workflow_config_migration_step.hpp" #include "workflow_config_schema_step.hpp" #include "workflow_config_version_step.hpp" +#include "workflow_runtime_config_step.hpp" #include #include @@ -39,6 +40,9 @@ void WorkflowDefaultStepRegistrar::RegisterUsedSteps( if (plugins.contains("config.schema.validate")) { registry->RegisterStep(std::make_shared(logger_, probeService_)); } + if (plugins.contains("runtime.config.build")) { + registry->RegisterStep(std::make_shared(logger_)); + } } } // namespace sdl3cpp::services::impl diff --git a/src/services/impl/workflow_frame_begin_step.cpp b/src/services/impl/workflow_frame_begin_step.cpp new file mode 100644 index 0000000..463467e --- /dev/null +++ b/src/services/impl/workflow_frame_begin_step.cpp @@ -0,0 +1,34 @@ +#include "workflow_frame_begin_step.hpp" +#include "workflow_step_io_resolver.hpp" + +#include + +namespace sdl3cpp::services::impl { + +WorkflowFrameBeginStep::WorkflowFrameBeginStep(std::shared_ptr logger) + : logger_(std::move(logger)) {} + +std::string WorkflowFrameBeginStep::GetPluginId() const { + return "frame.begin"; +} + +void WorkflowFrameBeginStep::Execute(const WorkflowStepDefinition& step, WorkflowContext& context) { + WorkflowStepIoResolver resolver; + const std::string deltaKey = resolver.GetRequiredInputKey(step, "delta"); + const std::string elapsedKey = resolver.GetRequiredInputKey(step, "elapsed"); + + const auto* delta = context.TryGet(deltaKey); + const auto* elapsed = context.TryGet(elapsedKey); + if (!delta || !elapsed) { + throw std::runtime_error("frame.begin requires delta and elapsed inputs"); + } + + if (logger_) { + logger_->Trace("WorkflowFrameBeginStep", "Execute", + "delta=" + std::to_string(*delta) + + ", elapsed=" + std::to_string(*elapsed), + "Starting frame workflow"); + } +} + +} // namespace sdl3cpp::services::impl diff --git a/src/services/impl/workflow_frame_begin_step.hpp b/src/services/impl/workflow_frame_begin_step.hpp new file mode 100644 index 0000000..2cb2e84 --- /dev/null +++ b/src/services/impl/workflow_frame_begin_step.hpp @@ -0,0 +1,21 @@ +#pragma once + +#include "../interfaces/i_workflow_step.hpp" +#include "../interfaces/i_logger.hpp" + +#include + +namespace sdl3cpp::services::impl { + +class WorkflowFrameBeginStep final : public IWorkflowStep { +public: + explicit WorkflowFrameBeginStep(std::shared_ptr logger); + + std::string GetPluginId() const override; + void Execute(const WorkflowStepDefinition& step, WorkflowContext& context) override; + +private: + std::shared_ptr logger_; +}; + +} // namespace sdl3cpp::services::impl diff --git a/src/services/impl/workflow_frame_physics_step.cpp b/src/services/impl/workflow_frame_physics_step.cpp new file mode 100644 index 0000000..8df209a --- /dev/null +++ b/src/services/impl/workflow_frame_physics_step.cpp @@ -0,0 +1,35 @@ +#include "workflow_frame_physics_step.hpp" +#include "workflow_step_io_resolver.hpp" + +#include + +namespace sdl3cpp::services::impl { + +WorkflowFramePhysicsStep::WorkflowFramePhysicsStep(std::shared_ptr physicsService, + std::shared_ptr logger) + : physicsService_(std::move(physicsService)), + logger_(std::move(logger)) {} + +std::string WorkflowFramePhysicsStep::GetPluginId() const { + return "frame.physics"; +} + +void WorkflowFramePhysicsStep::Execute(const WorkflowStepDefinition& step, WorkflowContext& context) { + if (!physicsService_) { + throw std::runtime_error("frame.physics requires an IPhysicsService"); + } + WorkflowStepIoResolver resolver; + const std::string deltaKey = resolver.GetRequiredInputKey(step, "delta"); + const auto* delta = context.TryGet(deltaKey); + if (!delta) { + throw std::runtime_error("frame.physics missing delta input"); + } + physicsService_->StepSimulation(static_cast(*delta)); + if (logger_) { + logger_->Trace("WorkflowFramePhysicsStep", "Execute", + "delta=" + std::to_string(*delta), + "Physics step completed"); + } +} + +} // namespace sdl3cpp::services::impl diff --git a/src/services/impl/workflow_frame_physics_step.hpp b/src/services/impl/workflow_frame_physics_step.hpp new file mode 100644 index 0000000..fe227a6 --- /dev/null +++ b/src/services/impl/workflow_frame_physics_step.hpp @@ -0,0 +1,24 @@ +#pragma once + +#include "../interfaces/i_workflow_step.hpp" +#include "../interfaces/i_logger.hpp" +#include "../interfaces/i_physics_service.hpp" + +#include + +namespace sdl3cpp::services::impl { + +class WorkflowFramePhysicsStep final : public IWorkflowStep { +public: + WorkflowFramePhysicsStep(std::shared_ptr physicsService, + std::shared_ptr logger); + + std::string GetPluginId() const override; + void Execute(const WorkflowStepDefinition& step, WorkflowContext& context) override; + +private: + std::shared_ptr physicsService_; + std::shared_ptr logger_; +}; + +} // namespace sdl3cpp::services::impl diff --git a/src/services/impl/workflow_runtime_config_step.cpp b/src/services/impl/workflow_runtime_config_step.cpp new file mode 100644 index 0000000..1218902 --- /dev/null +++ b/src/services/impl/workflow_runtime_config_step.cpp @@ -0,0 +1,48 @@ +#include "workflow_runtime_config_step.hpp" + +#include "runtime_config_builder.hpp" +#include "workflow_step_io_resolver.hpp" + +#include + +#include +#include +#include +#include +#include + +namespace sdl3cpp::services::impl { + +WorkflowRuntimeConfigStep::WorkflowRuntimeConfigStep(std::shared_ptr logger) + : logger_(std::move(logger)) {} + +std::string WorkflowRuntimeConfigStep::GetPluginId() const { + return "runtime.config.build"; +} + +void WorkflowRuntimeConfigStep::Execute(const WorkflowStepDefinition& step, WorkflowContext& context) { + WorkflowStepIoResolver resolver; + const std::string documentKey = resolver.GetRequiredInputKey(step, "document"); + const std::string pathKey = resolver.GetRequiredInputKey(step, "path"); + const std::string outputKey = resolver.GetRequiredOutputKey(step, "runtime"); + + const auto* documentHandle = context.TryGet>(documentKey); + if (!documentHandle || !(*documentHandle)) { + throw std::runtime_error("Workflow runtime.config.build missing document input '" + documentKey + "'"); + } + + std::filesystem::path pathValue; + if (const auto* path = context.TryGet(pathKey)) { + pathValue = *path; + } else if (const auto* pathString = context.TryGet(pathKey)) { + pathValue = *pathString; + } else { + throw std::runtime_error("Workflow runtime.config.build missing path input '" + pathKey + "'"); + } + + RuntimeConfigBuilder builder(logger_); + RuntimeConfig config = builder.Build(**documentHandle, pathValue); + context.Set(outputKey, std::move(config)); +} + +} // namespace sdl3cpp::services::impl diff --git a/src/services/impl/workflow_runtime_config_step.hpp b/src/services/impl/workflow_runtime_config_step.hpp new file mode 100644 index 0000000..17db741 --- /dev/null +++ b/src/services/impl/workflow_runtime_config_step.hpp @@ -0,0 +1,20 @@ +#pragma once + +#include "../interfaces/i_workflow_step.hpp" +#include "../interfaces/i_logger.hpp" +#include + +namespace sdl3cpp::services::impl { + +class WorkflowRuntimeConfigStep : public IWorkflowStep { +public: + explicit WorkflowRuntimeConfigStep(std::shared_ptr logger); + + std::string GetPluginId() const override; + void Execute(const WorkflowStepDefinition& step, WorkflowContext& context) override; + +private: + std::shared_ptr logger_; +}; + +} // namespace sdl3cpp::services::impl