#include "json_config_service.hpp" #include "../interfaces/i_logger.hpp" #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include namespace sdl3cpp::services::impl { namespace { constexpr int kExpectedSchemaVersion = 2; constexpr const char* kSchemaVersionKey = "schema_version"; constexpr const char* kConfigVersionKey = "configVersion"; constexpr const char* kExtendsKey = "extends"; constexpr const char* kDeleteKey = "@delete"; const char* SceneSourceName(SceneSource source) { switch (source) { case SceneSource::Config: return "config"; case SceneSource::Lua: return "lua"; default: 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'"); } std::string PointerToString(const rapidjson::Pointer& pointer) { rapidjson::StringBuffer buffer; pointer.Stringify(buffer); return buffer.GetString(); } std::filesystem::path NormalizeConfigPath(const std::filesystem::path& path) { std::error_code ec; auto canonicalPath = std::filesystem::weakly_canonical(path, ec); if (ec) { return std::filesystem::absolute(path); } return canonicalPath; } rapidjson::Document ParseJsonDocument(const std::filesystem::path& jsonPath, const std::string& description) { std::ifstream configStream(jsonPath); if (!configStream) { throw std::runtime_error("Failed to open " + description + ": " + jsonPath.string()); } rapidjson::IStreamWrapper inputWrapper(configStream); rapidjson::Document document; document.ParseStream(inputWrapper); if (document.HasParseError()) { throw std::runtime_error("Failed to parse " + description + " at " + jsonPath.string() + ": " + rapidjson::GetParseError_En(document.GetParseError())); } if (!document.IsObject()) { throw std::runtime_error("JSON " + description + " must contain an object at the root"); } return document; } std::vector ExtractExtendPaths(const rapidjson::Value& document, const std::filesystem::path& configPath) { std::vector paths; if (!document.HasMember(kExtendsKey)) { return paths; } const auto& extendsValue = document[kExtendsKey]; auto resolvePath = [&](const std::filesystem::path& candidate) { if (candidate.is_absolute()) { return candidate; } return configPath.parent_path() / candidate; }; if (extendsValue.IsString()) { paths.push_back(resolvePath(extendsValue.GetString())); } else if (extendsValue.IsArray()) { for (const auto& entry : extendsValue.GetArray()) { if (!entry.IsString()) { throw std::runtime_error("JSON member 'extends' must be a string or array of strings"); } paths.push_back(resolvePath(entry.GetString())); } } else { throw std::runtime_error("JSON member 'extends' must be a string or array of strings"); } return paths; } std::filesystem::path ResolveSchemaPath(const std::filesystem::path& configPath) { const std::filesystem::path schemaFile = "runtime_config_v2.schema.json"; std::vector candidates; if (!configPath.empty()) { candidates.push_back(configPath.parent_path() / "schema" / schemaFile); } candidates.push_back(std::filesystem::current_path() / "config" / "schema" / schemaFile); std::error_code ec; for (const auto& candidate : candidates) { if (!candidate.empty() && std::filesystem::exists(candidate, ec)) { return candidate; } } return {}; } void ApplyDeleteDirectives(rapidjson::Value& target, const rapidjson::Value& overlay, const std::shared_ptr& logger, const std::string& jsonPath) { if (!overlay.HasMember(kDeleteKey)) { return; } const auto& deletes = overlay[kDeleteKey]; if (!deletes.IsArray()) { throw std::runtime_error("JSON member '" + std::string(kDeleteKey) + "' must be an array of strings"); } for (const auto& entry : deletes.GetArray()) { if (!entry.IsString()) { throw std::runtime_error("JSON member '" + std::string(kDeleteKey) + "' must contain only strings"); } const char* key = entry.GetString(); if (target.HasMember(key)) { target.RemoveMember(key); if (logger) { logger->Trace("JsonConfigService", "ApplyDeleteDirectives", "jsonPath=" + jsonPath + ", key=" + std::string(key), "Removed key from merged config"); } } } } void MergeJsonValues(rapidjson::Value& target, const rapidjson::Value& overlay, rapidjson::Document::AllocatorType& allocator, const std::shared_ptr& logger, const std::string& jsonPath) { if (!overlay.IsObject()) { target.CopyFrom(overlay, allocator); return; } if (!target.IsObject()) { target.CopyFrom(overlay, allocator); return; } ApplyDeleteDirectives(target, overlay, logger, jsonPath); for (auto it = overlay.MemberBegin(); it != overlay.MemberEnd(); ++it) { const std::string memberName = it->name.GetString(); if (memberName == kDeleteKey) { continue; } if (target.HasMember(memberName.c_str())) { auto& targetValue = target[memberName.c_str()]; const auto& overlayValue = it->value; if (targetValue.IsObject() && overlayValue.IsObject()) { MergeJsonValues(targetValue, overlayValue, allocator, logger, jsonPath + "/" + memberName); } else { targetValue.CopyFrom(overlayValue, allocator); } } else { rapidjson::Value nameValue(memberName.c_str(), allocator); rapidjson::Value valueCopy; valueCopy.CopyFrom(it->value, allocator); target.AddMember(nameValue, valueCopy, allocator); } } } rapidjson::Document LoadConfigDocumentRecursive(const std::filesystem::path& configPath, const std::shared_ptr& logger, std::unordered_set& visited) { const auto normalizedPath = NormalizeConfigPath(configPath); const std::string pathKey = normalizedPath.string(); if (!visited.insert(pathKey).second) { throw std::runtime_error("Config extends cycle detected at " + pathKey); } if (logger) { logger->Trace("JsonConfigService", "LoadConfigDocumentRecursive", "configPath=" + pathKey, "Loading config document"); } rapidjson::Document document = ParseJsonDocument(normalizedPath, "config file"); auto extendPaths = ExtractExtendPaths(document, normalizedPath); if (document.HasMember(kExtendsKey)) { document.RemoveMember(kExtendsKey); } if (extendPaths.empty()) { visited.erase(pathKey); return document; } if (logger) { logger->Trace("JsonConfigService", "LoadConfigDocumentRecursive", "configPath=" + pathKey + ", extendsCount=" + std::to_string(extendPaths.size()), "Merging extended configs"); } rapidjson::Document merged; merged.SetObject(); auto& allocator = merged.GetAllocator(); for (const auto& extendPath : extendPaths) { auto baseDoc = LoadConfigDocumentRecursive(extendPath, logger, visited); MergeJsonValues(merged, baseDoc, allocator, logger, extendPath.string()); } MergeJsonValues(merged, document, allocator, logger, normalizedPath.string()); visited.erase(pathKey); return merged; } std::optional ReadVersionField(const rapidjson::Value& document, const char* fieldName, const std::filesystem::path& configPath) { if (!document.HasMember(fieldName)) { return std::nullopt; } const auto& value = document[fieldName]; if (value.IsInt()) { return value.GetInt(); } if (value.IsUint()) { return static_cast(value.GetUint()); } throw std::runtime_error("JSON member '" + std::string(fieldName) + "' must be an integer in " + configPath.string()); } std::optional ValidateSchemaVersion(const rapidjson::Value& document, const std::filesystem::path& configPath, const std::shared_ptr& logger) { const auto schemaVersion = ReadVersionField(document, kSchemaVersionKey, configPath); const auto configVersion = ReadVersionField(document, kConfigVersionKey, configPath); if (schemaVersion && configVersion && *schemaVersion != *configVersion) { throw std::runtime_error("JSON members 'schema_version' and 'configVersion' must match in " + configPath.string()); } const auto activeVersion = schemaVersion ? schemaVersion : configVersion; if (!activeVersion) { if (logger) { logger->Warn("JsonConfigService::LoadFromJson: Missing schema version in " + configPath.string() + "; assuming version " + std::to_string(kExpectedSchemaVersion)); } return std::nullopt; } if (logger) { logger->Trace("JsonConfigService", "ValidateSchemaVersion", "version=" + std::to_string(*activeVersion) + ", configPath=" + configPath.string()); } return activeVersion; } bool ApplyMigrations(rapidjson::Document& document, int fromVersion, int toVersion, const std::filesystem::path& configPath, const std::shared_ptr& logger, const std::shared_ptr& probeService) { if (fromVersion == toVersion) { return true; } if (logger) { logger->Warn("JsonConfigService::ApplyMigrations: No migration path from v" + std::to_string(fromVersion) + " to v" + std::to_string(toVersion) + " for " + configPath.string()); } if (probeService) { ProbeReport report{}; report.severity = ProbeSeverity::Error; report.code = "CONFIG_MIGRATION_MISSING"; report.jsonPath = ""; report.message = "No migration path from v" + std::to_string(fromVersion) + " to v" + std::to_string(toVersion) + " (see config/schema/MIGRATIONS.md)"; probeService->Report(report); } return false; } void ValidateSchemaDocument(const rapidjson::Document& document, const std::filesystem::path& configPath, const std::shared_ptr& logger, const std::shared_ptr& probeService) { const auto schemaPath = ResolveSchemaPath(configPath); if (schemaPath.empty()) { if (logger) { logger->Warn("JsonConfigService::ValidateSchemaDocument: Schema file not found for " + configPath.string()); } return; } rapidjson::Document schemaDocument = ParseJsonDocument(schemaPath, "schema file"); rapidjson::SchemaDocument schema(schemaDocument); rapidjson::SchemaValidator validator(schema); if (!document.Accept(validator)) { const std::string docPointer = PointerToString(validator.GetInvalidDocumentPointer()); const std::string schemaPointer = PointerToString(validator.GetInvalidSchemaPointer()); const std::string keyword = validator.GetInvalidSchemaKeyword(); const std::string message = "JSON schema validation failed at " + docPointer + " (schema " + schemaPointer + ", keyword=" + keyword + ")"; if (logger) { logger->Error("JsonConfigService::ValidateSchemaDocument: " + message + " configPath=" + configPath.string()); } if (probeService) { ProbeReport report{}; report.severity = ProbeSeverity::Error; report.code = "CONFIG_SCHEMA_INVALID"; report.jsonPath = docPointer; report.message = message; report.details = "schemaPointer=" + schemaPointer + ", keyword=" + keyword; probeService->Report(report); } throw std::runtime_error("JSON schema validation failed for " + configPath.string() + " at " + docPointer + " (schema " + schemaPointer + ", keyword=" + keyword + ")"); } if (logger) { logger->Trace("JsonConfigService", "ValidateSchemaDocument", "schemaPath=" + schemaPath.string() + ", configPath=" + configPath.string(), "Schema validation passed"); } } } // namespace JsonConfigService::JsonConfigService(std::shared_ptr logger, const char* argv0, std::shared_ptr probeService) : logger_(std::move(logger)), probeService_(std::move(probeService)), configJson_(), config_(RuntimeConfig{}) { if (logger_) { logger_->Trace("JsonConfigService", "JsonConfigService", "argv0=" + std::string(argv0 ? argv0 : "")); } config_.scriptPath = FindScriptPath(argv0); configJson_ = BuildConfigJson(config_, {}); logger_->Info("JsonConfigService initialized with default configuration"); } JsonConfigService::JsonConfigService(std::shared_ptr logger, const std::filesystem::path& configPath, bool dumpConfig, std::shared_ptr probeService) : logger_(std::move(logger)), probeService_(std::move(probeService)), configJson_(), config_(LoadFromJson(logger_, probeService_, configPath, dumpConfig, &configJson_)) { if (logger_) { logger_->Trace("JsonConfigService", "JsonConfigService", "configPath=" + configPath.string() + ", dumpConfig=" + std::string(dumpConfig ? "true" : "false")); } logger_->Info("JsonConfigService initialized from config file: " + configPath.string()); } JsonConfigService::JsonConfigService(std::shared_ptr logger, const RuntimeConfig& config, std::shared_ptr probeService) : logger_(std::move(logger)), probeService_(std::move(probeService)), configJson_(BuildConfigJson(config, {})), config_(config) { if (logger_) { logger_->Trace("JsonConfigService", "JsonConfigService", "config.width=" + std::to_string(config.width) + ", config.height=" + std::to_string(config.height) + ", config.scriptPath=" + config.scriptPath.string() + ", config.luaDebug=" + std::string(config.luaDebug ? "true" : "false") + ", config.windowTitle=" + config.windowTitle); } logger_->Info("JsonConfigService initialized with explicit configuration"); } std::filesystem::path JsonConfigService::FindScriptPath(const char* argv0) { if (logger_) { logger_->Trace("JsonConfigService", "FindScriptPath", "argv0=" + std::string(argv0 ? argv0 : "")); } std::filesystem::path executable; if (argv0 && *argv0 != '\0') { executable = std::filesystem::path(argv0); if (executable.is_relative()) { executable = std::filesystem::current_path() / executable; } } else { executable = std::filesystem::current_path(); } executable = std::filesystem::weakly_canonical(executable); std::filesystem::path scriptPath = executable.parent_path() / "scripts" / "cube_logic.lua"; if (!std::filesystem::exists(scriptPath)) { throw std::runtime_error("Could not find Lua script at " + scriptPath.string()); } return scriptPath; } RuntimeConfig JsonConfigService::LoadFromJson(std::shared_ptr logger, std::shared_ptr probeService, const std::filesystem::path& configPath, bool dumpConfig, std::string* configJson) { std::string args = "configPath=" + configPath.string() + ", dumpConfig=" + (dumpConfig ? "true" : "false"); logger->Trace("JsonConfigService", "LoadFromJson", args); std::unordered_set visitedPaths; rapidjson::Document document = LoadConfigDocumentRecursive(configPath, logger, visitedPaths); const auto activeVersion = ValidateSchemaVersion(document, configPath, logger); if (activeVersion && *activeVersion != kExpectedSchemaVersion) { const bool migrated = ApplyMigrations(document, *activeVersion, kExpectedSchemaVersion, configPath, logger, probeService); if (!migrated) { throw std::runtime_error("Unsupported schema version " + std::to_string(*activeVersion) + " in " + configPath.string() + "; expected " + std::to_string(kExpectedSchemaVersion) + " (see config/schema/MIGRATIONS.md)"); } } ValidateSchemaDocument(document, configPath, logger, probeService); if (dumpConfig || configJson) { rapidjson::StringBuffer buffer; rapidjson::PrettyWriter writer(buffer); writer.SetIndent(' ', 2); document.Accept(writer); if (dumpConfig) { std::cout << "Loaded runtime config (" << configPath << "):\n" << buffer.GetString() << '\n'; } if (configJson) { *configJson = buffer.GetString(); } } 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'"); } 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; } } return config; } std::string JsonConfigService::BuildConfigJson(const RuntimeConfig& config, const std::filesystem::path& configPath) { rapidjson::Document document; document.SetObject(); auto& allocator = document.GetAllocator(); auto addStringMember = [&](rapidjson::Value& target, const char* name, const std::string& value) { rapidjson::Value nameValue(name, allocator); rapidjson::Value stringValue(value.c_str(), allocator); target.AddMember(nameValue, stringValue, allocator); }; document.AddMember("schema_version", kExpectedSchemaVersion, allocator); document.AddMember("configVersion", kExpectedSchemaVersion, allocator); rapidjson::Value scriptsObject(rapidjson::kObjectType); addStringMember(scriptsObject, "entry", config.scriptPath.string()); scriptsObject.AddMember("lua_debug", config.luaDebug, allocator); document.AddMember("scripts", scriptsObject, allocator); rapidjson::Value runtimeObject(rapidjson::kObjectType); addStringMember(runtimeObject, "scene_source", SceneSourceName(config.sceneSource)); document.AddMember("runtime", runtimeObject, allocator); rapidjson::Value windowObject(rapidjson::kObjectType); addStringMember(windowObject, "title", config.windowTitle); rapidjson::Value sizeObject(rapidjson::kObjectType); sizeObject.AddMember("width", config.width, allocator); sizeObject.AddMember("height", config.height, allocator); windowObject.AddMember("size", sizeObject, allocator); std::filesystem::path scriptsDir = config.scriptPath.parent_path(); rapidjson::Value mouseGrabObject(rapidjson::kObjectType); mouseGrabObject.AddMember("enabled", config.mouseGrab.enabled, allocator); mouseGrabObject.AddMember("grab_on_click", config.mouseGrab.grabOnClick, allocator); mouseGrabObject.AddMember("release_on_escape", config.mouseGrab.releaseOnEscape, allocator); mouseGrabObject.AddMember("start_grabbed", config.mouseGrab.startGrabbed, allocator); mouseGrabObject.AddMember("hide_cursor", config.mouseGrab.hideCursor, allocator); mouseGrabObject.AddMember("relative_mode", config.mouseGrab.relativeMode, allocator); mouseGrabObject.AddMember("grab_mouse_button", rapidjson::Value(config.mouseGrab.grabMouseButton.c_str(), allocator), allocator); mouseGrabObject.AddMember("release_key", rapidjson::Value(config.mouseGrab.releaseKey.c_str(), allocator), allocator); windowObject.AddMember("mouse_grab", mouseGrabObject, allocator); document.AddMember("window", windowObject, allocator); rapidjson::Value bgfxObject(rapidjson::kObjectType); bgfxObject.AddMember("renderer", rapidjson::Value(config.bgfx.renderer.c_str(), allocator), allocator); rapidjson::Value materialObject(rapidjson::kObjectType); materialObject.AddMember("enabled", config.materialX.enabled, allocator); materialObject.AddMember("document", rapidjson::Value(config.materialX.documentPath.string().c_str(), allocator), allocator); materialObject.AddMember("shader_key", rapidjson::Value(config.materialX.shaderKey.c_str(), allocator), allocator); materialObject.AddMember("material", rapidjson::Value(config.materialX.materialName.c_str(), allocator), allocator); materialObject.AddMember("library_path", rapidjson::Value(config.materialX.libraryPath.string().c_str(), allocator), allocator); rapidjson::Value libraryFolders(rapidjson::kArrayType); for (const auto& folder : config.materialX.libraryFolders) { libraryFolders.PushBack(rapidjson::Value(folder.c_str(), allocator), allocator); } materialObject.AddMember("library_folders", libraryFolders, allocator); materialObject.AddMember("use_constant_color", config.materialX.useConstantColor, allocator); rapidjson::Value constantColor(rapidjson::kArrayType); constantColor.PushBack(config.materialX.constantColor[0], allocator); constantColor.PushBack(config.materialX.constantColor[1], allocator); constantColor.PushBack(config.materialX.constantColor[2], allocator); materialObject.AddMember("constant_color", constantColor, allocator); if (!config.materialXMaterials.empty()) { rapidjson::Value materialsArray(rapidjson::kArrayType); for (const auto& material : config.materialXMaterials) { rapidjson::Value entry(rapidjson::kObjectType); entry.AddMember("enabled", material.enabled, allocator); entry.AddMember("document", rapidjson::Value(material.documentPath.string().c_str(), allocator), allocator); entry.AddMember("shader_key", rapidjson::Value(material.shaderKey.c_str(), allocator), allocator); entry.AddMember("material", rapidjson::Value(material.materialName.c_str(), allocator), allocator); entry.AddMember("use_constant_color", material.useConstantColor, allocator); rapidjson::Value materialColor(rapidjson::kArrayType); materialColor.PushBack(material.constantColor[0], allocator); materialColor.PushBack(material.constantColor[1], allocator); materialColor.PushBack(material.constantColor[2], allocator); entry.AddMember("constant_color", materialColor, allocator); materialsArray.PushBack(entry, allocator); } materialObject.AddMember("materials", materialsArray, allocator); } rapidjson::Value renderingObject(rapidjson::kObjectType); renderingObject.AddMember("bgfx", bgfxObject, allocator); renderingObject.AddMember("materialx", materialObject, allocator); rapidjson::Value atmosphericsObject(rapidjson::kObjectType); atmosphericsObject.AddMember("ambient_strength", config.atmospherics.ambientStrength, allocator); atmosphericsObject.AddMember("fog_density", config.atmospherics.fogDensity, allocator); rapidjson::Value fogColor(rapidjson::kArrayType); fogColor.PushBack(config.atmospherics.fogColor[0], allocator); fogColor.PushBack(config.atmospherics.fogColor[1], allocator); fogColor.PushBack(config.atmospherics.fogColor[2], allocator); atmosphericsObject.AddMember("fog_color", fogColor, allocator); rapidjson::Value skyColor(rapidjson::kArrayType); skyColor.PushBack(config.atmospherics.skyColor[0], allocator); skyColor.PushBack(config.atmospherics.skyColor[1], allocator); skyColor.PushBack(config.atmospherics.skyColor[2], allocator); atmosphericsObject.AddMember("sky_color", skyColor, allocator); atmosphericsObject.AddMember("gamma", config.atmospherics.gamma, allocator); atmosphericsObject.AddMember("exposure", config.atmospherics.exposure, allocator); atmosphericsObject.AddMember("enable_tone_mapping", config.atmospherics.enableToneMapping, allocator); atmosphericsObject.AddMember("enable_shadows", config.atmospherics.enableShadows, allocator); atmosphericsObject.AddMember("enable_ssgi", config.atmospherics.enableSSGI, allocator); atmosphericsObject.AddMember("enable_volumetric_lighting", config.atmospherics.enableVolumetricLighting, allocator); atmosphericsObject.AddMember("pbr_roughness", config.atmospherics.pbrRoughness, allocator); atmosphericsObject.AddMember("pbr_metallic", config.atmospherics.pbrMetallic, allocator); renderingObject.AddMember("atmospherics", atmosphericsObject, allocator); document.AddMember("rendering", renderingObject, allocator); rapidjson::Value budgetsObject(rapidjson::kObjectType); budgetsObject.AddMember("vram_mb", static_cast(config.budgets.vramMB), allocator); budgetsObject.AddMember("max_texture_dim", config.budgets.maxTextureDim, allocator); budgetsObject.AddMember("gui_text_cache_entries", static_cast(config.budgets.guiTextCacheEntries), allocator); budgetsObject.AddMember("gui_svg_cache_entries", static_cast(config.budgets.guiSvgCacheEntries), allocator); document.AddMember("budgets", budgetsObject, allocator); rapidjson::Value crashObject(rapidjson::kObjectType); crashObject.AddMember("heartbeat_timeout_ms", config.crashRecovery.heartbeatTimeoutMs, allocator); crashObject.AddMember("heartbeat_poll_interval_ms", config.crashRecovery.heartbeatPollIntervalMs, allocator); crashObject.AddMember("memory_limit_mb", static_cast(config.crashRecovery.memoryLimitMB), allocator); crashObject.AddMember("gpu_hang_frame_time_multiplier", config.crashRecovery.gpuHangFrameTimeMultiplier, allocator); crashObject.AddMember("max_consecutive_gpu_timeouts", static_cast(config.crashRecovery.maxConsecutiveGpuTimeouts), allocator); crashObject.AddMember("max_lua_failures", static_cast(config.crashRecovery.maxLuaFailures), allocator); crashObject.AddMember("max_file_format_errors", static_cast(config.crashRecovery.maxFileFormatErrors), allocator); crashObject.AddMember("max_memory_warnings", static_cast(config.crashRecovery.maxMemoryWarnings), allocator); document.AddMember("crash_recovery", crashObject, allocator); rapidjson::Value bindingsObject(rapidjson::kObjectType); auto addBindingMember = [&](const char* name, const std::string& value) { rapidjson::Value nameValue(name, allocator); rapidjson::Value stringValue(value.c_str(), allocator); bindingsObject.AddMember(nameValue, stringValue, allocator); }; 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}, }}; for (const auto& spec : bindingSpecs) { addBindingMember(spec.name, config.inputBindings.*(spec.member)); } auto addMappingObject = [&](const char* name, const std::unordered_map& mappings, rapidjson::Value& target) { rapidjson::Value mappingObject(rapidjson::kObjectType); for (const auto& [key, value] : mappings) { rapidjson::Value keyValue(key.c_str(), allocator); rapidjson::Value stringValue(value.c_str(), allocator); mappingObject.AddMember(keyValue, stringValue, allocator); } target.AddMember(rapidjson::Value(name, allocator), mappingObject, allocator); }; addMappingObject("gamepad_button_actions", config.inputBindings.gamepadButtonActions, bindingsObject); addMappingObject("gamepad_axis_actions", config.inputBindings.gamepadAxisActions, bindingsObject); bindingsObject.AddMember("gamepad_axis_action_threshold", config.inputBindings.gamepadAxisActionThreshold, allocator); rapidjson::Value inputObject(rapidjson::kObjectType); inputObject.AddMember("bindings", bindingsObject, allocator); document.AddMember("input", inputObject, allocator); std::filesystem::path projectRoot = scriptsDir.parent_path(); rapidjson::Value pathsObject(rapidjson::kObjectType); if (!scriptsDir.empty()) { addStringMember(pathsObject, "scripts", scriptsDir.string()); } if (!projectRoot.empty()) { addStringMember(pathsObject, "project_root", projectRoot.string()); addStringMember(pathsObject, "shaders", (projectRoot / "shaders").string()); } else { addStringMember(pathsObject, "shaders", "shaders"); } document.AddMember("paths", pathsObject, allocator); rapidjson::Value guiObject(rapidjson::kObjectType); rapidjson::Value fontObject(rapidjson::kObjectType); fontObject.AddMember("use_freetype", config.guiFont.useFreeType, allocator); fontObject.AddMember("font_path", rapidjson::Value(config.guiFont.fontPath.string().c_str(), allocator), allocator); fontObject.AddMember("font_size", config.guiFont.fontSize, allocator); guiObject.AddMember("font", fontObject, allocator); guiObject.AddMember("opacity", config.guiOpacity, allocator); document.AddMember("gui", guiObject, allocator); if (!configPath.empty()) { addStringMember(document, "config_file", configPath.string()); } rapidjson::StringBuffer buffer; rapidjson::Writer writer(buffer); document.Accept(writer); return buffer.GetString(); } } // namespace sdl3cpp::services::impl