Files
SDL3CPlusPlus/src/services/impl/json_config_service.cpp
2026-01-09 17:20:00 +00:00

1444 lines
66 KiB
C++

#include "json_config_service.hpp"
#include "../interfaces/i_logger.hpp"
#include <rapidjson/document.h>
#include <rapidjson/error/en.h>
#include <rapidjson/istreamwrapper.h>
#include <rapidjson/schema.h>
#include <rapidjson/stringbuffer.h>
#include <rapidjson/writer.h>
#include <rapidjson/prettywriter.h>
#include <array>
#include <algorithm>
#include <cctype>
#include <fstream>
#include <iostream>
#include <optional>
#include <stdexcept>
#include <system_error>
#include <unordered_set>
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<std::filesystem::path> ExtractExtendPaths(const rapidjson::Value& document,
const std::filesystem::path& configPath) {
std::vector<std::filesystem::path> 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<std::filesystem::path> 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<ILogger>& 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<ILogger>& 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<ILogger>& logger,
std::unordered_set<std::string>& 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<int> 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<int>(value.GetUint());
}
throw std::runtime_error("JSON member '" + std::string(fieldName) + "' must be an integer in " +
configPath.string());
}
std::optional<int> ValidateSchemaVersion(const rapidjson::Value& document,
const std::filesystem::path& configPath,
const std::shared_ptr<ILogger>& 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<ILogger>& logger,
const std::shared_ptr<IProbeService>& 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<ILogger>& logger,
const std::shared_ptr<IProbeService>& 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<ILogger> logger,
const char* argv0,
std::shared_ptr<IProbeService> 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<ILogger> logger,
const std::filesystem::path& configPath,
bool dumpConfig,
std::shared_ptr<IProbeService> 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<ILogger> logger,
const RuntimeConfig& config,
std::shared_ptr<IProbeService> 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<ILogger> logger,
std::shared_ptr<IProbeService> 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<std::string> 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<rapidjson::StringBuffer> 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<std::string> 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<std::filesystem::path> 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<uint32_t>(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<uint32_t>(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<BindingSpec, 18> 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<std::string, std::string>& 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<float>(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<float>(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<float, 3>& 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<float>(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<float>(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<float>(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<float>(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<float>(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<float>(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<size_t>(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<uint32_t>(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<uint64_t>(config.budgets.vramMB), allocator);
budgetsObject.AddMember("max_texture_dim", config.budgets.maxTextureDim, allocator);
budgetsObject.AddMember("gui_text_cache_entries",
static_cast<uint64_t>(config.budgets.guiTextCacheEntries),
allocator);
budgetsObject.AddMember("gui_svg_cache_entries",
static_cast<uint64_t>(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<uint64_t>(config.crashRecovery.memoryLimitMB), allocator);
crashObject.AddMember("gpu_hang_frame_time_multiplier",
config.crashRecovery.gpuHangFrameTimeMultiplier, allocator);
crashObject.AddMember("max_consecutive_gpu_timeouts",
static_cast<uint64_t>(config.crashRecovery.maxConsecutiveGpuTimeouts), allocator);
crashObject.AddMember("max_lua_failures",
static_cast<uint64_t>(config.crashRecovery.maxLuaFailures), allocator);
crashObject.AddMember("max_file_format_errors",
static_cast<uint64_t>(config.crashRecovery.maxFileFormatErrors), allocator);
crashObject.AddMember("max_memory_warnings",
static_cast<uint64_t>(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<BindingSpec, 18> 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<std::string, std::string>& 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<rapidjson::StringBuffer> writer(buffer);
document.Accept(writer);
return buffer.GetString();
}
} // namespace sdl3cpp::services::impl